Lesson 21 · Module 3.4 — Type-level programming
Here is the surprise that catches every reader: when the type you test in a conditional is a
union sitting bare in the extends position, the conditional doesn't run once on the
whole union — it runs once per member and unions the results back together. Knowing this rule
explains why so many utility types behave the way they do.
In plain English first. The title — “one check, run per union member” — just means
this. A conditional type is a type-level if: T extends U ? X : Y reads “if
T fits U, use X, otherwise Y.” The twist: when
T is a union like string | number (standing bare on the left of
extends), TypeScript does not test the whole union at once — it runs that one little
if once for each member and joins the answers with |. Picture a
.map() over the union: check string, check number, then glue the
results back together. “Distributive” is just the formal word for that per-member looping.
A naked type parameter in a conditional distributes over a union. With
T = A | B, the type T extends U ? X : Y automatically becomes
(A extends U ? X : Y) | (B extends U ? X : Y). “Naked” means T stands alone
on the left of extends, not wrapped in anything.
ToArrayThe “textbook example” — the canonical one from the TypeScript Handbook — is ToArray,
below. It wraps each member in an array, and because T is naked, the conditional splits
across the union before doing the work:
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;
// step 1 — distribute: ToArray<string> | ToArray<number>
// step 2 — each branch: string[] | number[]
// resolves to: string[] | number[]
If conditionals did not distribute, you would get (string | number)[] — one
array of the union. The distribution is exactly the difference between “array of each” and “array of the
whole.” Read any naked conditional this way: substitute one union member at a time.
never edge case follows from the same ruleA union with zero members is never. Distributing over it produces zero results, so the
whole conditional collapses to never — even though, written non-distributively, you might
expect a branch to fire:
type Wrap<T> = T extends any ? T[] : never;
type Z = Wrap<never>; // never (distributing over the empty union yields nothing)
This trips people constantly: Wrap<never> is never, not
never[], precisely because there are no members to run the branch on.
[ ]Sometimes you want the conditional to see the whole union as a single thing. The trick is to
make the parameter non-naked by wrapping both extends sides in a one-element tuple;
[T] is no longer a bare type parameter, so distribution stops. The clearest way to see it is on
an ordinary union — string | number:
// distributive (naked T): the check runs on each member
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string | number>; // "yes" | "no" (string yes, number no; answers joined)
// non-distributive (wrapped): the check runs on the whole union once
type IsAllString<T> = [T] extends [string] ? "yes" : "no";
type B = IsAllString<string | number>; // "no" (is the whole union a string? no)
The brackets flip the question from “is each member a string?” (which yields a union of answers,
"yes" | "no") to “is the entire union a string?” (one answer, "no"). That
is the whole effect of the off-switch — no edge cases involved.
Now the real payoff, and where you will actually meet this: a correct IsNever.
The naive version distributes over the empty union and collapses to never (the trap from the
section just above); wrapping the sides compares never as one whole type and gives a real
answer:
// the naive version is broken: naked T distributes over the empty union
type IsNeverBad<T> = T extends never ? "yes" : "no";
type IsNever<T> = [T] extends [never] ? "yes" : "no";
type Bad = IsNeverBad<never>; // never (broken — distributed over the empty union)
type Good = IsNever<never>; // "yes" (works — never tested as one whole type)
So when you see [T] extends [U] in real code, read it as a deliberate signal:
“treat the union as one — do not distribute.” The brackets are the off-switch.
Bare T extends … → distributes (loops over union members). Wrapped
[T] extends […], or T appearing inside something like
{ x: T } or T[] on the left → does not distribute. The rule is
purely about whether T is naked in the extends position.
Recall, don’t re-read. (Answers reveal on click.)
ToArray<string | number> resolve to, where ToArray<T> = T extends any ? T[] : never?T extends never ? "y" : "n" give never for T = never?[T] extends [U] ? X : Y, the square brackets are there to…For this topic, lean on the handbook:
TypeScript Handbook — Distributive Conditional Types,
which spells out both the ToArray distribution and the [T] extends [U]
off-switch. Best way to feel it: paste ToArray and the bracketed
IsNever into the TS Playground and compare
the results on never.
Got a conditional whose result surprised you on a union? Paste it into the Playground and ask “does
this one distribute?” Next lesson (3.5) brings template-literal types together with infer
to parse strings at the type level.