Yuzhe's Blog

yuzhes

Union to Tuple

Union to Tuple

Challenge Link

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

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

This is the most clever part. Let’s trace through it:

  1. U extends any ? () => U : never distributes the union into function types:

    • A | B | C(() => A) | (() => B) | (() => C)
  2. UnionToIntersection<...> converts this to an intersection of functions:

    • (() => A) & (() => B) & (() => C)
  3. An intersection of functions with different return types behaves like an overloaded function. When you infer R from the return type, TypeScript picks the last overload’s return type.

  4. 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]

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:

  1. Contravariant inference for union-to-intersection
  2. Overload resolution behavior for “last of union”
  3. Recursive tuple construction with proper never detection

Each is non-trivial on its own; combining them requires deep understanding of TypeScript’s type system internals.

Key Takeaways