Union to Tuple
Union to Tuple
Challenge
Implement a type UnionToTuple<T> that converts a union type to a tuple type. The order of elements in the tuple doesn't matter — any permutation is acceptable.
UnionToTuple<1> // [1]
UnionToTuple<'any' | 'a'> // ['any', 'a'] or ['a', 'any']The result must be a single tuple, not a union of tuples.
Solution
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: never
type LastOfUnion<U> =
UnionToIntersection<U extends any ? () => U : never> extends () => infer R
? R
: never
type UnionToTuple<U, Last = LastOfUnion<U>> =
[U] extends [never]
? []
: [...UnionToTuple<Exclude<U, Last>>, Last]This solution combines three techniques: union-to-intersection, extracting the last element of a union, and recursive tuple building.
Breaking it down
Step 1 — UnionToIntersection (from challenge #55)
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: neverConverts A | B | C to A & B & C using contravariant inference. (See challenge #55 for a detailed explanation.)
Step 2 — Extract the "last" member of a union
type LastOfUnion<U> =
UnionToIntersection<U extends any ? () => U : never> extends () => infer R
? R
: neverThis is the most clever part. Let's trace through it:
U extends any ? () => U : neverdistributes the union into function types:A | B | C→(() => A) | (() => B) | (() => C)
UnionToIntersection<...>converts this to an intersection of functions:(() => A) & (() => B) & (() => C)
An intersection of functions with different return types behaves like an overloaded function. When you
infer Rfrom the return type, TypeScript picks the last overload's return type.So
R=C(or whichever member TypeScript considers "last" in the union).
Note: The order TypeScript processes union members is an implementation detail, not guaranteed by the spec. But it's consistent enough to make this work.
Step 3 — Recursively build the tuple
type UnionToTuple<U, Last = LastOfUnion<U>> =
[U] extends [never]
? []
: [...UnionToTuple<Exclude<U, Last>>, Last]- Base case: if
Uisnever(empty union), return[] - Recursive case: extract
Lastfrom the union, put it at the end of the tuple, and recurse with the remaining members
The [U] extends [never] check (wrapped in tuple) avoids distributive behavior — a bare U extends never would distribute and never match.
Walkthrough:
U = 'a' | 'b' | 'c'
Round 1: Last = 'c'
→ [...UnionToTuple<'a' | 'b'>, 'c']
Round 2: U = 'a' | 'b', Last = 'b'
→ [...UnionToTuple<'a'>, 'b']
Round 3: U = 'a', Last = 'a'
→ [...UnionToTuple<never>, 'a']
Round 4: U = never
→ []
Unwind: [...[], 'a'] = ['a']
[...['a'], 'b'] = ['a', 'b']
[...['a', 'b'], 'c'] = ['a', 'b', 'c'] ✓Deep Dive
Why [U] extends [never] instead of U extends never?
// ❌ This doesn't work:
type Bad<U> = U extends never ? [] : [U]
type Test = Bad<never> // never (not [])never is the empty union. When used in a distributive conditional (U extends ...), it distributes over zero members and produces never. Wrapping in a tuple [U] extends [never] disables distribution.
The overload trick for "last of union"
When TypeScript encounters an intersection of function types, it treats them as overloads. Inference picks the last overload signature:
type Overloaded = (() => 'a') & (() => 'b') & (() => 'c')
type R = Overloaded extends () => infer R ? R : never
// R = 'c' (last overload wins)This is an implementation detail but has been stable across TypeScript versions since 3.x.
Union collapse behavior
The challenge explicitly notes these collapses:
UnionToTuple<any | 'a'> // same as UnionToTuple<any>
UnionToTuple<unknown | 'a'> // same as UnionToTuple<unknown>
UnionToTuple<never | 'a'> // same as UnionToTuple<'a'>
UnionToTuple<'a' | 'a' | 'a'> // same as UnionToTuple<'a'>These are fundamental union rules in TypeScript — any and unknown absorb other members, never disappears, and duplicates are deduplicated.
Why this is considered "hard"
This challenge requires combining three advanced concepts:
- Contravariant inference for union-to-intersection
- Overload resolution behavior for "last of union"
- Recursive tuple construction with proper
neverdetection
Each is non-trivial on its own; combining them requires deep understanding of TypeScript's type system internals.
Key Takeaways
- Union → Intersection → Overloaded function → Last return type is the pipeline for extracting one member at a time from a union
[T] extends [never]is the correct way to check forneverwithout triggering distribution- Function overload inference picks the last overload's return type — this is a crucial trick for many advanced types
- Union ordering is an implementation detail — don't rely on specific element positions in the resulting tuple
- This pattern is a building block for many "convert union to X" type challenges
