UnionReplace<T, U> rewrites a union according to a list of [from, to] type pairs: one conditional distributes over the members, another recurses through the pairs.
Two loops with no loop syntax: distribute a conditional over the union, and recurse through a tuple of replacement pairs.
Given a union of types and an array of type pairs to replace (like [[string, number], [Date, null]]), UnionReplace<T, U> returns a new union in which every matching member has been swapped according to those pairs. The solution combines two staples of advanced TypeScript: distributing a conditional type over every union member, and recursively walking a tuple of instructions. You'll reuse the pattern whenever a type takes configuration as input.
For example
[object Object]Given an union of types and array of type pairs to replace ([[string, number], [Date, null]]), return a new union replaced with the type pairs.
View on GitHub: https://tsch.js.org/13580
Change the following code to make the test cases pass (no type check errors).
π Lifetime-License is leaving on August 10, 2026
Get it now for $29
One-time payment. Lifetime access to all pro challenges.
Start with the finished type:
type UnionReplace<T, U extends [any, any][]> = T extends unknown
? U extends [infer Pair extends [any, any], ...infer Rest extends [any, any][]]
? [T] extends [Pair[0]]
? Pair[1]
: UnionReplace<T, Rest>
: T
: neverThe algorithm: for each member of the union, walk through the replacement pairs one by one. The first pair whose left side matches wins; if none match, the member passes through unchanged. Two loops, both expressed as type-level idioms.
T extends unknown is always true, so it never filters anything; it exists only to trigger distribution. When the checked type is a bare generic parameter holding a union, TypeScript evaluates the whole conditional once per member and unions the results. For T = number | string, everything after the first ? runs twice: once with T = number, once with T = string.
This is essential, because "replace string with null" must be decided per member. Inside the branch, T always means one single member.
U extends [infer Pair extends [any, any], ...infer Rest extends [any, any][]] is the type-level equivalent of destructuring const [pair, ...rest] = pairs. Variadic tuple inference splits the pairs list into its first element and the remainder, and the extends annotations on the infer sites (a constraint written directly on the inference site, available since TypeScript 4.7) keep both halves properly typed so that Pair[0], Pair[1] and the recursive call all typecheck.
For U = [[string, null], [Date, Function]]:
// Pair = [string, null]
// Rest = [[Date, Function]]If the pattern doesn't match at all, U is the empty tuple []: we've exhausted the replacement list without a hit, so the : T branch returns the member untouched.
[T] extends [Pair[0]] asks whether the current member is assignable to the pair's "from" type, with both sides wrapped in a one-element tuple. Strictly speaking the wrapping isn't load-bearing here: by the time this check runs, the outer conditional has already distributed, so T stands for a single member and a bare T extends Pair[0] would behave the same for these tests. It's a defensive habit worth keeping, though. The brackets guarantee a single whole-type assignability test and protect against surprises (such as a never type argument making a distributive conditional vanish) if the surrounding code is ever refactored. On a match we return Pair[1], the "to" type; otherwise we recurse with UnionReplace<T, Rest>, trying the next pair for the same member.
UnionReplace<Function | Date | object, [[Date, string], [Function, undefined]]>:
Function: [Function] extends [Date] fails β recurse β [Function] extends [Function] matches β undefinedDate: [Date] extends [Date] matches immediately β stringobject: fails against Date, fails against Function, pair list runs out β stays objectThe three per-member results are unioned back together: undefined | string | object, exactly what the test expects.
[[string, null], [Date, Function]] applied to number | string, the string member matches the first pair immediately, while the number member checks both pairs, matches neither, and falls through to the base case unchanged.object in the last test flows through the whole pair list into the base case.switch.Once you see it, the solution is two familiar patterns composed: distribute to iterate a union, destructure-and-recurse to iterate a tuple. Keeping straight which conditional distributes (the outer one, on purpose) and where distribution is deliberately kept out of play (the tuple-wrapped match check) is the real skill this challenge trains.
This challenge is originally from here.