#13580β€’Hard

Replace Union

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]

Challenge Instructions: Replace Union

Hard

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.

Loading...

Detailed Explanation

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
  : never

The 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.

The outer loop: distributing over the union

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.

The inner loop: recursing through the pairs

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.

The match check

[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.

Tracing a full example

UnionReplace<Function | Date | object, [[Date, string], [Function, undefined]]>:

The three per-member results are unioned back together: undefined | string | object, exactly what the test expects.

Edge cases the tests cover

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.

Share this challenge

Learn the Concepts