IsUnion
IsUnion
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]>// falseSolution
type IsUnion<T, U = T> = [T] extends [never]
? false
: T extends T
? [U] extends [T]
? false
: true
: neverThe 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:
| Branch | T (current member) | U (original) |
|---|---|---|
| 1 | string | string | number |
| 2 | number | string | 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?
- If
Twas not a union →U === T(same single type) →[U] extends [T]istrue→ returnfalse - If
Twas a union →Uis the full union,Tis one member →[string | number] extends [string]isfalse→ returntrue
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 : trueWhy 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 unionThe boolean case is a great litmus test: TypeScript internally represents boolean as true | false, so IsUnion<boolean> correctly returns true.
Key Takeaways
- Copy parameter pattern (
U = T) preserves the original type before distribution mutatesT T extends Tis the idiom to iterate over union members via distributive conditional types[U] extends [T]with tuple wrapping is a non-distributive comparison — essential when you want to compare a union against one of its own members[T] extends [never]is the standard guard for detectingneverwithout accidentally distributing- TypeScript's
booleanistrue | falseunder the hood — a useful edge case to remember
