Yuzhe's Blog

yuzhes

IsUnion

IsUnion

Challenge Link

Challenge

Implement a type IsUnion, which takes an input type T and returns whether T resolves to a union type.

type case1 = IsUnion<string>           // false
type case2 = IsUnion<string | number>  // true
type case3 = IsUnion<[string | number]>// false

Solution

type IsUnion<T, U = T> = [T] extends [never]
  ? false
  : T extends T
    ? [U] extends [T]
      ? false
      : true
    : never

The trick is combining a copy parameter (U = T) with distributive conditional types and a non-distributive [U] extends [T] comparison.

Breaking it down

Step 1 — Store the original union: U = T

Before any conditional kicks in, we capture the original type T in a second parameter U. This is critical because the next step is going to mutate T.

Step 2 — Handle never: [T] extends [never]

never is technically an empty union, but we want IsUnion<never> to return false, not distribute into nothing. Wrapping in [] disables distribution so we get a clean false branch.

Step 3 — Distribute: T extends T

T extends T looks like a no-op, but it triggers distribution over union members. When T = string | number, TypeScript evaluates two separate branches:

BranchT (current member)U (original)
1stringstring | number
2numberstring | number

Step 4 — Compare member vs original: [U] extends [T]

Wrapping in [] prevents further distribution. Now we ask: does the original type extend the current single member?

The union of results across all branches collapses to true when any branch found a mismatch.

Deep Dive

Why does the copy parameter work?

The key insight: distributive conditional types only affect the checked type parameter in T extends Constraint. Any other type variables in scope (U) keep their original value throughout all branches.

// Inside T extends T when T = string | number:
// Branch where T = string:
//   U is still string | number ← unchanged!
//   [U] extends [T] → [string | number] extends [string] → false ✓

Without U, we’d have no reference to the original union and couldn’t make the comparison.

The [U] extends [T] vs U extends T distinction

Using bare U extends T would trigger another distributive expansion — this time over U. We need a single, non-distributing check, so we wrap both sides in tuples.

// ❌ Wrong — distributes over U as well
type Check = U extends T ? false : true

// ✅ Correct — tuple prevents distribution
type Check = [U] extends [T] ? false : true

Why false : never in the outer ternary?

The outer T extends T is exhaustive (always true), so the never branch is unreachable. It exists purely to satisfy TypeScript’s syntax requirements for conditional types.

Edge cases

IsUnion<never>          // false — handled by [T] extends [never] guard
IsUnion<string>         // false — single type, U === T
IsUnion<boolean>        // true! boolean is secretly true | false in TS
IsUnion<string | never> // false — never collapses: string | never = string
IsUnion<[string|number]>// false — union is inside a tuple, T is not a union

The boolean case is a great litmus test: TypeScript internally represents boolean as true | false, so IsUnion<boolean> correctly returns true.

Key Takeaways