open atlas
↑ Back to track
TypeScript type system, deep TS · 05 · 02

Distributive conditionals

A naked type parameter in a conditional distributes over each member of a union; wrapping it in a tuple opts out — and a conditional over never yields never because never is the empty union.

TS Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A teammate writes type NonEmpty<T> = T extends "" ? never : T to strip empty strings, tests it on "a" | "", gets "a", ships it. Two weeks later a different call site passes never (the type of an exhausted union) and NonEmpty<never> quietly returns never — which is correct, but for a reason nobody on the team can explain, and now a generic helper silently produces never where they expected the input back. The behaviour is not a bug. It is distribution, and never is the empty union. Once you can read distribution precisely, half of the standard utility types stop being magic.

The rule, stated precisely

Why does this matter? Because every union-level utility type — Exclude, Extract, Filter — works because of this rule, and every wrong IsNever or IsString predicate breaks because of it. When the type being checked in a conditional is a naked type parameterT extends ..., where T appears alone, not wrapped in anything — and T is instantiated with a union, the conditional distributes: it is applied to each member of the union independently, and the results are unioned back together.

type ToArray<T> = T extends unknown ? T[] : never;

type A = ToArray<string | number>;
//   ^? type A = string[] | number[]
//   NOT (string | number)[]  — distribution split the union first

The compiler rewrites ToArray<string | number> as ToArray<string> | ToArray<number> = string[] | number[]. The check runs once per member.

Opting out: wrap the parameter

Distribution only triggers for a naked parameter. Wrap it in a one-element tuple and the union is checked as a single whole:

type IsString<T> = [T] extends [string] ? true : false;

type B = IsString<string | number>;
//   ^? type B = false   ("string | number" is not assignable to string — checked as a whole)

// Without the wrap, distribution gives a surprising union:
type IsStringNaked<T> = T extends string ? true : false;
type C = IsStringNaked<string | number>;
//   ^? type C = boolean   (true | false — distributed: string->true, number->false)

Notice C is boolean. That is true | false — distribution produced true for the string member and false for the number member, then unioned them. This is the single most common distribution surprise: a predicate that should answer “yes or no about the whole type” instead answers per-member and collapses to boolean.

never is the empty union

never is the type with zero members — the empty union. Distributing a conditional over zero members produces zero results, and the union of nothing is never:

type Wrap<T> = T extends unknown ? T[] : never;

type D = Wrap<never>;
//   ^? type D = never   (NOT never[] — there were no members to map over)

This is the famous “conditional over never yields never” gotcha. Wrap<never> is not never[]; the conditional never runs at all because there is nothing to distribute over. To force evaluation when you genuinely want to test for never, opt out of distribution:

type IsNever<T> = [T] extends [never] ? true : false;

type E = IsNever<never>;
//   ^? type E = true    (wrapped: the empty union IS assignable to [never])
Why this works

Why does boolean also distribute? boolean is internally true | false — a union of two literal types. So T extends true ? ... : ... over T = boolean runs twice, once for true and once for false. If you see a conditional mysteriously returning boolean or evaluating both arms when you passed a single boolean, this is why. Wrap with [boolean] extends [true] to treat it atomically.

Rebuilding the standard library with distribution

Exclude and Extract are pure distribution — three tokens each:

type MyExclude<T, U> = T extends U ? never : T;   // drop members of T assignable to U
type MyExtract<T, U> = T extends U ? T : never;   // keep members of T assignable to U
type MyNonNullable<T> = T extends null | undefined ? never : T;

type F = MyExclude<"a" | "b" | "c", "a">;
//   ^? type F = "b" | "c"
//   distributed: "a"->never, "b"->"b", "c"->"c"; never drops out of the union

The key insight: distribution maps each member to either itself or never, and never disappears when unioned (X | never = X). That is the whole mechanism behind filtering a union — map the unwanted members to never and they vanish. You can build a general filter the same way:

type FilterByKind<T, K> = T extends { kind: K } ? T : never;
type Events = { kind: "click"; x: number } | { kind: "key"; code: string };
type G = FilterByKind<Events, "click">;
//   ^? type G = { kind: "click"; x: number }
Quiz

Given `type P<T> = T extends any ? T[] : never`, what does `type R = P<never>` resolve to, and why?

Complete the analogy

A distributive conditional is like a mail sorter: each letter (union member) is processed one at a time and dropped into an outbox. To stop the sorter from opening the envelope and processing each letter separately — to handle the whole bundle as one — you ____ the type parameter.

Recall before you leave
  1. 01
    State the precise condition under which a conditional type distributes, and give the two-token change that opts out.
  2. 02
    Why does a distributive conditional over `never` return `never` rather than the mapped result, and how do you test for `never` reliably?
  3. 03
    How are Exclude, Extract, and NonNullable just distribution, and what role does `never` play in the result?
Recap

Distribution is the single rule that makes union-level type programming work: a naked type parameter checked against a union runs the conditional once per member and unions the outcomes. Wrapping in [T] evaluates the union as a whole — essential for IsNever, IsString-style predicates, and anything that should answer about the whole type rather than each member. never is the empty union, so distributing over it yields never; boolean is true | false and distributes into both arms. Exclude/Extract/NonNullable are distribution mapping members to never, which vanishes on recombination. Now when you see a generic predicate collapse to boolean or a filtered type return never where you expected a value, the first thing you check is whether the parameter is naked and whether it might be never.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.