skip to content

Distributive Conditionals & Disabling Distribution

When a conditional type tests a naked type parameter and gets a union, it evaluates once per member and unions the results. Interviewers love this because it explains both how Exclude works and why wrapping in tuples turns the behavior off.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In TypeScript, given `type ToArray<T> = T extends unknown ? T[] : never`, why does `ToArray<string | number>` evaluate to `string[] | number[]` rather than `(string | number)[]`?

level: middleimportance: must knowfreq 62%

answer

  1. think multiplication over addition
  2. checked side must be bare T
  3. each member evaluated separately
  4. results unioned back together
  5. T[] or {x:T} blocks it

basics

~20 s

A conditional type whose checked type is a bare type parameter distributes: TypeScript applies it to each union member separately and unions the results. ToArray runs once on string and once on number, producing string[] | number[].

solid answer

~50 s

A conditional type is *distributive* when the type being checked is a naked type parameter — just `T`, not `T[]` or `{ x: T }`. Instantiate such a type with a union and TypeScript does not test the union as a whole; it substitutes each member for `T`, evaluates the conditional once per member, and unions the results. So `ToArray<string | number>` becomes `ToArray<string> | ToArray<number>`, which is `string[] | number[]`. If I wanted `(string | number)[]` I would stop distribution by wrapping both sides in one-element tuples — `[T] extends [unknown] ? T[] : never` — which tests the union as a single type. This mechanic is also what makes union filtering possible: return `T` in one branch and `never` in the other and the unwanted members disappear, because `never` contributes nothing to a union.

code

typescript · 8 lines
typescript
type ToArray<T> = T extends unknown ? T[] : never;
type Distributed = ToArray<string | number>; // string[] | number[]

type ToArrayWhole<T> = [T] extends [unknown] ? T[] : never;
type NotDistributed = ToArrayWhole<string | number>; // (string | number)[]

const a: Distributed = ['x'];
const b: NotDistributed = ['x', 1];

go deeper

for a junior

Know that a conditional type looks like T extends U ? X : Y and that handing it a union can produce a union back rather than one answer. Being able to read the result is enough at this stage.

for a middle

Be ready to state the precondition out loud — a naked type parameter instantiated with a union — and to walk the substitution member by member. Show the tuple wrapping that switches the behaviour off.

for a senior

Expect to explain why a shared helper silently changed a consumer's type, using distribution as the diagnosis, and to decide deliberately whether a given helper should map over members or judge the union as a whole.

for a principal

Own the API consequence: whether an exported generic distributes is observable behaviour that consumers depend on, so treat flipping it as a breaking change and pin it with type-level tests rather than leaving it implicit.

## The rule A conditional type has the shape `T extends U ? X : Y`. TypeScript gives it one extra behaviour that surprises almost everyone the first time: when the type being *checked* is a **naked type parameter** and the type argument supplied for it is a **union**, the conditional is not evaluated once against the whole union. It is evaluated once per union member, and the results are combined back into a union. That is called *distribution*, by analogy with distributing multiplication over addition: ```ts type ToArray<T> = T extends unknown ? T[] : never; type A = ToArray<string | number>; // evaluated as ToArray<string> | ToArray<number> // => string[] | number[] ``` The condition `T extends unknown` is deliberately trivially true here — the conditional exists only to trigger distribution, not to make a decision. `T extends any` is used interchangeably for the same purpose. ## What "naked" means Naked means the checked position contains the type parameter and nothing else. These distribute: ```ts type One<T> = T extends string ? 1 : 0; // naked T type Two<T> = T extends { id: string } ? 1 : 0; // naked T, structural condition ``` These do **not**, because `T` is wrapped in something before the check: ```ts type Three<T> = T[] extends string[] ? 1 : 0; // T[] is not naked type Four<T> = { x: T } extends { x: string } ? 1 : 0; type Five<T> = [T] extends [string] ? 1 : 0; // the standard opt-out ``` Only the checked (left) side matters. What appears after `extends`, and what the branches produce, has no effect on whether distribution happens. There is a second precondition that is easy to miss: distribution is a property of *instantiating a type parameter*. A union written literally in the checked position does not distribute, because no substitution takes place: ```ts type Direct = string | number extends string ? 1 : 0; // => 0, one single check ``` ## Why the compiler does this Distribution is what turns conditional types into a mapping operation over unions, which is where most of their practical value lives. Two idioms fall out of it directly. **Mapping.** Transform every member and keep the union shape — that is exactly the `ToArray` example, and the same pattern wraps each member in a `Promise`, a box type, or a nullable. **Filtering.** Return `T` in one branch and `never` in the other. Because `never` is the empty union, a member that resolves to `never` simply vanishes when the results are unioned: ```ts type OnlyStrings<T> = T extends string ? T : never; type B = OnlyStrings<string | 42 | boolean>; // => string ``` Notice `boolean` disappeared entirely. `boolean` is internally the union `true | false`, so it splits into two members, and neither is a string. This filtering idiom is the machinery underneath the built-in union-filtering utilities in `lib.es5.d.ts`. ## Turning it off The canonical opt-out is to wrap **both** sides in a one-element tuple: ```ts type IsExactlyString<T> = [T] extends [string] ? true : false; type C = IsExactlyString<string | number>; // => false ``` Now the checked type is `[T]`, a tuple containing the parameter rather than the bare parameter, so no substitution-per-member happens; the compiler asks a single assignability question about the whole union. Wrapping only one side does not work — the two sides must be comparable, so both get the tuple. ## Two edge inputs worth remembering `never` is the empty union. Distributing over zero members yields zero results, so a distributive conditional given `never` returns `never` without evaluating either branch. And instantiating a distributive conditional with `any` produces the union of *both* branches, because `any` is treated as matching and not matching simultaneously: ```ts type YesNo<T> = T extends string ? 'yes' : 'no'; type D = YesNo<any>; // => 'yes' | 'no' ``` ## It costs nothing at runtime All of this is compile-time arithmetic over types. TypeScript erases types on emit, so a distributive conditional produces no JavaScript at all — no checks, no branches, no allocation. The cost is compiler time and reader comprehension, never program speed. ## What an interviewer is listening for The strong answer names the precondition (naked type parameter plus a union argument), states the substitute-and-union mechanic, and shows the tuple opt-out. The weak answer treats distribution as something conditional types always do, and is then unable to explain why `[T] extends [U]` behaves differently or why a filter with `never` removes members instead of inserting `never` into the result.

  • Does `type Direct = string | number extends string ? 1 : 0` distribute?
    No. Distribution happens when a type *parameter* in the checked position is instantiated with a union. Here the union is written literally, so there is no substitution — the compiler asks one question, is `string | number` assignable to `string`, gets no, and returns `0`.
  • What comes out of a distributive conditional when you instantiate it with `any`?
    The union of both branches. For `type YesNo<T> = T extends string ? 'yes' : 'no'`, `YesNo<any>` is `'yes' | 'no'`. `any` is deliberately treated as both matching and not matching the condition, which is a useful smell test: if a helper suddenly returns a union of both outcomes, an `any` leaked into it.
  • How do you use distribution to remove members from a union?
    Return `T` in one branch and `never` in the other: `T extends string ? T : never`. Each member is tested on its own, and the members that resolve to `never` disappear when the results are unioned, because `never` is the empty union and contributes nothing.

saying these in an interview costs you the question

  • Says every conditional type tests the whole union at once
  • Expects (string | number)[] and calls the union result a bug
  • Thinks distribution happens even when T is wrapped, like T[]
  • Claims distribution adds a runtime check to the emitted JavaScript
  • Confuses distribution with mapped-type iteration over keys

context

open as a page

In TypeScript, `type IsString<T> = T extends string ? true : false` gives `boolean` for `IsString<string | number>`. Explain that result and how you would make the check apply to the whole union instead.

level: middleimportance: should knowfreq 50%

basics

~20 s

The conditional distributes over the union: string yields true, number yields false, and true | false is exactly how boolean is defined. Wrapping both sides in one-element tuples, [T] extends [string], disables distribution and returns false.

open as a page

In TypeScript, a helper `type Stripped<T> = Omit<T, 'id'>` applied to a discriminated union collapses it into a single object type and loses the members' own properties. Why does that happen, and how do you keep the union?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Omit is not distributive: T is used inside keyof and Pick, never as a conditional's naked checked type, and keyof of a union yields only the shared keys. Wrap it yourself — T extends unknown ? Omit<T, K> : never — to apply it per member.

open as a page

In TypeScript, why does `type Check<T> = T extends string ? 'yes' : 'no'` evaluate `Check<never>` to `never`, and how do you write a type that reports whether T is `never`?

level: seniorimportance: should knowfreq 34%

basics

~20 s

never is the empty union, and a distributive conditional maps itself over each union member. With zero members there is nothing to map, so the result is the empty union again: never. Detect it non-distributively with [T] extends [never].

open as a page

You maintain a shared TypeScript types package. Whether an exported generic type distributes over unions is invisible in its signature — how do you decide which behaviour a type should have, and how do you keep changing it from silently breaking consumers?

level: principalimportance: nice to knowfreq 16%

basics

~20 s

Decide by intent: per-member transforms should distribute, whole-type judgments should not. Then pin the choice with type-level tests that include union, never and any inputs, encode it in the name, and treat flipping it as a breaking release.

open as a page