Lesson 21 · Module 3.4 — Type-level programming

Distributive conditionals: one check, run per union member

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.

The one rule

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.

Watch it distribute: ToArray

The “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.

The never edge case follows from the same rule

A 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.

Switching distribution OFF: wrap both sides in [ ]

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.

Spotting it on sight

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.

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

What does ToArray<string | number> resolve to, where ToArray<T> = T extends any ? T[] : never?
Why does a distributive T extends never ? "y" : "n" give never for T = never?
Reading [T] extends [U] ? X : Y, the square brackets are there to…

Primary source

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.

Try it yourself

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.