Phase 5 · Type-level programming · Lesson 5.4
AdvancedDistributive conditional types
Why a conditional type applied to a union runs once per member, how that builds Exclude and Extract, how to switch it off with `[T]`, and the `never`, `boolean` and UnionToIntersection puzzles interviewers love.
25 min
Write type ToArray<T> = T extends any ? T[] : never and try ToArray<string | number>. You probably expect (string | number)[]. You get string[] | number[]. That is not a bug: it's distribution, one of the most useful and most confusing rules in the type system. It is how Exclude and Extract work, why IsNever<never> returns never instead of true, and why a type involving boolean suddenly splits in two. Once you can predict it, a whole category of "why is this type weird?" questions goes away.
What distribution is
When a conditional type checks a naked type parameter (T extends ..., with T alone on the left), and T is instantiated with a union, the conditional is applied to each member separately, and the results are unioned back together.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
type _t1 = Expect<Equal<Result, string[] | number[]>>;Step by step, TypeScript computes:
ToArray<string | number>- =
ToArray<string> | ToArray<number>(distribute over the union) - =
(string extends any ? string[] : never) | (number extends any ? number[] : never) - =
string[] | number[]
Think of it as a map over the union's members. T extends any (or T extends unknown) is a condition that is always true; it's written only to trigger the distribution.
When it happens (and when it doesn't)
All three conditions must hold:
- The checked type is a type parameter, on its own.
T extends ...distributes;T[] extends ...,Promise<T> extends ...and[T] extends ...do not. - The conditional is generic: distribution happens when the type parameter is instantiated. A conditional written directly on a union does not distribute.
- The argument is a union (or
never, orboolean; more on those below).
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type IsString<T> = T extends string ? "yes" : "no";
type _t1 = Expect<Equal<IsString<string | number>, "yes" | "no">>;
// Written inline, not through a type parameter: no distribution.
type Inline = (string | number) extends string ? "yes" : "no";
type _t2 = Expect<Equal<Inline, "no">>;
// T is wrapped, so it is not naked: no distribution.
type Wrapped<T> = T[] extends unknown[] ? [T] : never;
type _t3 = Expect<Equal<Wrapped<string | number>, [string | number]>>;IsString<string | number> answers per member: string says "yes", number says "no", so you get both. The inline version asks whether the whole union fits inside string, which it doesn't.
Quick check
What is Box<"a" | "b">?
type Box<T> = T extends unknown ? { value: T } : never;Exclude, Extract and NonNullable
Distribution turns a conditional type into a filter over a union. Return never for members you want to drop: never disappears in a union (A | never is just A).
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type Status = "idle" | "loading" | "success" | "error";
type Busy = MyExclude<Status, "idle" | "success">;
type _t1 = Expect<Equal<Busy, "loading" | "error">>;
type Done = MyExtract<Status, "success" | "error" | "cancelled">;
type _t2 = Expect<Equal<Done, "success" | "error">>;Walk through MyExclude<Status, "idle" | "success">:
| Member | extends "idle" | "success"? | Result |
|---|---|---|
"idle" | yes | never |
"loading" | no | "loading" |
"success" | yes | never |
"error" | no | "error" |
Union the results and the nevers vanish: "loading" | "error".
Extract is extremely handy with discriminated unions, because U can be a shape, not just a literal:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Event =
| { type: "click"; x: number; y: number }
| { type: "key"; key: string }
| { type: "scroll"; delta: number };
type ClickEvent = Extract<Event, { type: "click" }>;
type _t1 = Expect<Equal<ClickEvent, { type: "click"; x: number; y: number }>>;
type NotClick = Exclude<Event, { type: "click" }>;
type _t2 = Expect<Equal<NotClick, { type: "key"; key: string } | { type: "scroll"; delta: number }>>;NonNullable
For years NonNullable<T> was defined as T extends null | undefined ? never : T, a distributive filter. Since TypeScript 4.8 it's defined as T & {}. The result is the same for unions, and it works better with generic code.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type OldNonNullable<T> = T extends null | undefined ? never : T;
type _t1 = Expect<Equal<OldNonNullable<string | null | undefined>, string>>;
type _t2 = Expect<Equal<NonNullable<string | null | undefined>, string>>;
// `{}` means "any non-nullish value", so intersecting removes null and undefined.
type _t3 = Expect<Equal<(string | null) & {}, string>>;
type _t4 = Expect<Equal<NonNullable<unknown>, {}>>;T & {} works because null & {} and undefined & {} reduce to never, while string & {} reduces to string. And unknown & {} is {}: "anything except null and undefined".
Turning distribution off: [T] extends [U]
Sometimes you want to test the union as a whole. Wrap both sides in a one-element tuple. [T] is no longer a naked type parameter, so no distribution happens.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type ToArrayWhole<T> = [T] extends [any] ? T[] : never;
type _t1 = Expect<Equal<ToArrayWhole<string | number>, (string | number)[]>>;
type AllStrings<T> = [T] extends [string] ? true : false;
type _t2 = Expect<Equal<AllStrings<"a" | "b">, true>>;
type _t3 = Expect<Equal<AllStrings<"a" | 1>, false>>;Wrapping both sides matters: [T] extends [string] compares the tuples, which is the same as comparing T with string, just without distribution. Any wrapper works (T[] extends string[], { x: T } extends ...), but the tuple form is the widely recognised idiom.
never is the empty union
Here is the classic trap:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type IsNeverBroken<T> = T extends never ? true : false;
type _t1 = Expect<Equal<IsNeverBroken<never>, never>>;
type IsNever<T> = [T] extends [never] ? true : false;
type _t2 = Expect<Equal<IsNever<never>, true>>;
type _t3 = Expect<Equal<IsNever<string>, false>>;
type _t4 = Expect<Equal<IsNever<any>, false>>;IsNeverBroken<never> returns never, not true, and not false. Why? never is the union with zero members. Distribution maps over the members, there are none, so the result is the empty union, never. The conditional is never even evaluated.
This also means every distributive conditional type returns never for never: ToArray<never> is never, Exclude<never, X> is never. That's usually what you want, except when you're testing for never itself. The fix, again, is [T] extends [never].
boolean is true | false
boolean is not a primitive the type system treats as one piece. It's literally the union true | false, so it distributes:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type ToArray<T> = T extends any ? T[] : never;
type _t1 = Expect<Equal<ToArray<boolean>, false[] | true[]>>;
type ToArrayWhole<T> = [T] extends [any] ? T[] : never;
type _t2 = Expect<Equal<ToArrayWhole<boolean>, boolean[]>>;false[] | true[] means "an array of all-true or an array of all-false". An array like [true, false] would not fit. If a generic type unexpectedly splits in two when you pass boolean (or a type containing optional properties, whose type includes undefined), look for a naked T extends.
Quick check
What is Flags<boolean | "auto">?
type Flags<T> = T extends unknown ? { v: T } : never;UnionToIntersection, step by step
This is the famous one. Given A | B | C, produce A & B & C:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: never;
type Merged = UnionToIntersection<{ id: number } | { name: string } | { admin: boolean }>;
type _t1 = Expect<Equal<Merged, { id: number } & { name: string } & { admin: boolean }>>;
const user: Merged = { id: 1, name: "Ada", admin: true };
console.log(user);▶ Try it in the TypeScript Playground
It combines two rules you already know. Take U = A | B.
Step 1: distribute to build a union of functions. The inner part, U extends any ? (arg: U) => void : never, is a distributive conditional. It produces one function per member:
((arg: A) => void) | ((arg: B) => void)
Step 2: infer from a parameter position. Now TypeScript checks that union against (arg: infer I) => void. To match, it has to find one I that works for every function in the union. From the previous lesson: when one infer gets several candidates in a parameter (contravariant) position, TypeScript combines them with an intersection.
Step 3: why intersection is correct. A function type (arg: I) => void can stand in for (arg: A) => void only if it accepts every A. To stand in for both, it must accept an argument that is an A and a B. So I = A & B.
The outer conditional is not distributive, because its checked type is an expression (the whole parenthesized conditional), not a naked type parameter. That's what lets it see the union of functions all at once.
Edge cases
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends (arg: infer I) => void
? I
: never;
type _t1 = Expect<Equal<UnionToIntersection<string | number>, never>>;
type _t2 = Expect<Equal<UnionToIntersection<boolean>, never>>;
type _t3 = Expect<Equal<UnionToIntersection<(() => 1) | (() => 2)>, (() => 1) & (() => 2)>>;string & numberis impossible, so it reduces tonever.booleanistrue | false, andtrue & falseisnever. A classic follow-up question.- An intersection of function types behaves like an overloaded function. That fact is the basis of an even stranger trick (
LastOf<Union>, then union-to-tuple), which you might see in type-challenge repositories. It relies on unspecified union ordering, so don't use it in production code.
Spot the error
This helper should reject any union and only accept a single literal type. It returns false for single types, as intended, but it also returns false for IsUnion<string | number>, where it should say true. Why?
type IsUnion<T> = T extends any ? ([T] extends [T] ? false : true) : never;
type R = IsUnion<string | number>;
// ^? type R = falseShow the answer
After distribution, each branch only sees a single member as T, so [T] extends [T] compares string with string, and false is returned for every member. You need a second type parameter that holds a copy of the original, undistributed union:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type IsUnion<T, All = T> = T extends any ? ([All] extends [T] ? false : true) : never;
type _t1 = Expect<Equal<IsUnion<string | number>, true>>;
type _t2 = Expect<Equal<IsUnion<string>, false>>;
type _t3 = Expect<Equal<IsUnion<boolean>, true>>;The default All = T is captured before distribution, so it still holds string | number. Inside each branch, [string | number] extends [string] is false, so the member reports true: "the whole thing is bigger than me". The [...] wrapping stops All from distributing too.
Recap
- A conditional type distributes when it checks a naked type parameter and that parameter receives a union: it runs once per member and unions the results.
Exclude<T, U>=T extends U ? never : T;Extract<T, U>=T extends U ? T : never.neverdisappears from unions.NonNullable<T>is nowT & {}; it used to be a distributive filter.- Wrap in a tuple,
[T] extends [U], to test the union as a whole. neveris the empty union, so distributive types returnneverfor it;IsNever<T>needs[T] extends [never].booleanistrue | falseand distributes:ToArray<boolean>isfalse[] | true[].UnionToIntersectiondistributes into a union of functions, then infers the parameter; contravariant inference intersects the candidates.anyin a conditional returns both branches.
Interview cards
1 / 8