Distributive conditional types

Applies a conditional type to each member of a union separately when the checked type is a naked type parameter.

Signature
T extends U ? X : Y            // distributes when T is a naked type parameter
[T] extends [U] ? X : Y        // does not distribute
  • Added in TypeScript 2.8

Parameters

ParameterTypeDescription
$Ttype parameterThe checked type. Distribution only happens when this is a naked type parameter - not wrapped in a tuple, array, or any other constructor.
$UtypeThe type checked against.

Return value

A union of the per-member results, or a single result when distribution is disabledWhen the checked type is a bare type parameter instantiated with a union, the conditional is applied to each member and the results are unioned. Wrapping both sides in tuples tests the union as a whole and returns one result.

Errors

Nothing raises an error; the failure mode is a silently wrong type, most often never when the union being distributed over is never or when the union has more members than expected.

Changelog

  • PHP 2.8Introduced with conditional types. Exclude, Extract and NonNullable in lib.d.ts all depend on this behaviour.
  • PHP 4.1Recursive conditional types made distributing over long unions in a tail position practical without hitting the depth limit.

When the type on the left of extends is a naked type parameter and it receives a union, the conditional is not evaluated once against the whole union. It is evaluated once per member and the results are unioned back together. ToArray<string | number> becomes ToArray<string> | ToArray<number>, which is string[] | number[] — not (string | number)[].

"Naked" means the parameter appears alone. Wrap it in anything — a tuple, an array, a Promise — and distribution stops.

When to reach for it

Distribution is the entire mechanism behind union filtering. Exclude<T, U> is literally T extends U ? never : T: each member is tested, the matches become never, and never vanishes from a union. Any time you want to map or filter a union, lean on it deliberately.

What trips people up

Two things, and both produce never. First, distributing over never means distributing over an empty union, so no branch runs and the result is never — which is why IsNever<T> has to be written as [T] extends [never]. Second, an accidental distribution splits a union you meant to keep whole, so a check like "is this exactly string | number" quietly answers per member.

Example

Example · typescript
type ToArray<T> = T extends unknown ? T[] : never;
type Distributed = ToArray<string | number>;
// string[] | number[]   (evaluated once per member)

type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type NotDistributed = ToArrayNonDist<string | number>;
// (string | number)[]   (evaluated once for the whole union)

type MyExclude<T, U> = T extends U ? never : T;
type Kept = MyExclude<"a" | "b" | "c", "a" | "c">; // "b"

// distributing over never visits zero members
type Naive<T> = T extends never ? true : false;
type Oops = Naive<never>;                  // never, not true
type IsNever<T> = [T] extends [never] ? true : false;
type Right = IsNever<never>;               // true

When to use it

  • Write Exclude or Extract by hand to filter a union of action types
  • Turn a union of string keys into a union of handler object types
  • Compare two unions as whole types with the [T] extends [U] tuple trick

Frequently asked questions

Why does my conditional type return a union instead of one type?

Because the checked type is a naked type parameter and it was given a union, so the conditional ran once per member and the results were unioned. Wrap both sides of extends in single-element tuples - [T] extends [U] - to evaluate it once against the whole union.

Why does my conditional type return never for never?

never is the empty union, so a distributive conditional has zero members to distribute over and produces never without taking either branch. Test for it with [T] extends [never] ? true : false.

What exactly counts as a naked type parameter?

The type parameter appearing on its own to the left of extends, with nothing wrapped around it. T is naked; [T], T[], Promise<T> and { value: T } are not, and none of them distribute.

More examples

How Exclude and Extract are built

Diff tests each member independently: "a" and "c" match and become never, and never disappears when a union is assembled, leaving "b" | "d". The Handlers alias shows the constructive side - because T is naked, each string in the union produces its own object type, giving a discriminated union rather than one object whose type field is the whole union.

Example · typescript
type Diff<T, U> = T extends U ? never : T;   // this is Exclude
type Filter<T, U> = T extends U ? T : never; // this is Extract

type T30 = Diff<"a" | "b" | "c" | "d", "a" | "c" | "f">;   // "b" | "d"
type T31 = Filter<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "a" | "c"

type MyNonNullable<T> = T extends null | undefined ? never : T;
type T32 = MyNonNullable<string | number | null | undefined>; // string | number

// mapping a union to a union of objects
type Action = "add" | "remove";
type Handlers<T extends string> = T extends unknown
  ? { type: T; run: (payload: string) => void }
  : never;
type H = Handlers<Action>;
// { type: "add"; run: ... } | { type: "remove"; run: ... }

When distribution is the bug

A returns a useless union of both answers because the check ran three times. Wrapping both sides in single-element tuples in IsStrOrNumStrict tests the union as one type and gives the intended yes/no. D shows the same effect arriving by accident - the parameter is inside Promise<...>, so it is no longer naked and never distributes.

Example · typescript
// looks like "is T exactly string | number?" - it is not
type IsStrOrNum<T> = T extends string | number ? "yes" : "no";
type A = IsStrOrNum<string | number | boolean>;
// "yes" | "no"  - each member answered separately

// tuple-wrap to ask the question you meant
type IsStrOrNumStrict<T> = [T] extends [string | number] ? "yes" : "no";
type B = IsStrOrNumStrict<string | number | boolean>; // "no"
type C = IsStrOrNumStrict<string | number>;           // "yes"

// distribution also stops when T is not naked
type Wrapped<T> = Promise<T> extends Promise<string> ? 1 : 0;
type D = Wrapped<string | number>; // 0, checked as a whole

Discussion

  • Be the first to comment on this lesson.