EdgeCases Logo
Feb 2026
TypeScript
Expert
8 min read

TypeScript Distributive Conditional Types: The Union Distribution Rule

T extends U ? X : Y behaves completely differently when T is a union—TypeScript distributes the conditional over each member. Master this rule to unlock advanced utility types.

typescript
conditional-types
unions
type-system
utility-types
advanced
edge-case

Write T extends U ? X : Y where T is a union, and TypeScript doesn't evaluate it once—it distributes the conditional over each union member separately, then unions the results. This behavior powers many utility types, but it catches developers off guard when they don't expect it.

The Distribution Rule

When a conditional type's checked type (T in T extends U ? X : Y) is a naked type parameter that receives a union type, the conditional distributes over each member:

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

// What you might expect:
type Result = ToArray<string | number>;
// Result = (string | number)[]  ❌ WRONG

// What actually happens:
// 1. Distribute: ToArray<string> | ToArray<number>
// 2. Evaluate each: string[] | number[]
type Result = ToArray<string | number>;
// Result = string[] | number[]  ✅ ACTUAL

When Distribution Happens

Distribution requires all three conditions:

  1. Conditional type: Must be T extends U ? X : Y form
  2. Naked type parameter: T must appear alone, not wrapped
  3. Union type argument: The type passed to T must be a union
// ✅ Distributes: T is naked
type A<T> = T extends string ? 'yes' : 'no';
type R1 = A<string | number>;  // 'yes' | 'no'

// ❌ Does NOT distribute: T is wrapped in array
type B<T> = T[] extends string[] ? 'yes' : 'no';
type R2 = B<string | number>;  // 'no' (union array ≠ string array)

// ❌ Does NOT distribute: T is wrapped in tuple
type C<T> = [T] extends [string] ? 'yes' : 'no';
type R3 = C<string | number>;  // 'no' (evaluated as whole)

Practical Examples

Built-in Utility Types

Several built-in types rely on distribution:

// Exclude: Remove types from union
type Exclude<T, U> = T extends U ? never : T;

type Result = Exclude<'a' | 'b' | 'c', 'a'>;
// Distributes:
// ('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b') | ...
// = never | 'b' | 'c'
// = 'b' | 'c'

// Extract: Keep only matching types
type Extract<T, U> = T extends U ? T : never;

type Result2 = Extract<string | number | boolean, string | number>;
// = string | number

// NonNullable: Remove null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type Result3 = NonNullable<string | null | undefined>;
// = string

Filtering Object Keys

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

// Get keys where value extends a type
type KeysMatching<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

type StringKeys = KeysMatching<User, string>;
// = 'name' | 'email'

type NumberKeys = KeysMatching<User, number>;
// = 'id' | 'age'

Preventing Distribution

Sometimes you want to evaluate the union as a whole, not distribute. Wrap the type parameter in a tuple or array:

// Distributes (naked T)
type IsString<T> = T extends string ? true : false;
type R1 = IsString<string | number>;  // boolean (true | false)

// Does NOT distribute (T wrapped in tuple)
type IsStringOnly<T> = [T] extends [string] ? true : false;
type R2 = IsStringOnly<string | number>;  // false

// Practical use: Check if type is exactly a union
type IsUnion<T, C = T> = T extends C
  ? [C] extends [T]
    ? false
    : true
  : never;

type R3 = IsUnion<string>;         // false
type R4 = IsUnion<string | number>;  // true

The never Edge Case

never is the empty union—it has zero members. Distributing over zero members produces never, not the false branch:

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

// never distributes over... nothing
type Result = ToArray<never>;
// = never (not 'fallback'!)

// Why? Distribution: never = union of 0 members
// ToArray<never> = union of 0 results = never

To handle never specially, check for it first with the wrapped pattern:

type ToArraySafe<T> = [T] extends [never]
  ? never  // or some fallback
  : T extends unknown
    ? T[]
    : never;

type R1 = ToArraySafe<string | number>;  // string[] | number[]
type R2 = ToArraySafe<never>;            // never (explicit, not accidental)

Distribution with infer

Distribution also affects infer in conditional types:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

// Distributes over union of Promises
type Result = UnwrapPromise<Promise<string> | Promise<number>>;
// = string | number

// Mixed union: some match, some don't
type Result2 = UnwrapPromise<Promise<string> | number>;
// = string | number (Promise unwrapped, number passed through)

Extracting Function Parameters

type ParamTypes<T> = T extends (...args: infer P) => unknown ? P : never;

type Fn1 = (a: string) => void;
type Fn2 = (a: number, b: boolean) => void;

// Distribution over function union
type Params = ParamTypes<Fn1 | Fn2>;
// = [string] | [number, boolean]

// Useful for overload-like behavior handling

Common Gotchas

1. Unexpected boolean

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

// boolean is just true | false
type R = IsString<string | number>;
// = true | false
// = boolean  <-- Simplified by TypeScript

// This often surprises developers expecting true or false

2. Object Type Distribution

// Objects in unions distribute too
type Example<T> = T extends { a: number } ? T['a'] : never;

type Result = Example<{ a: 1 } | { a: 2 } | { b: 3 }>;
// Distributes:
// { a: 1 } extends { a: number } ? 1 : never → 1
// { a: 2 } extends { a: number } ? 2 : never → 2
// { b: 3 } extends { a: number } ? ... : never → never
// = 1 | 2

3. Mapped Types Don't Distribute

// This is NOT distribution—it's mapped type behavior
type Nullable<T> = { [K in keyof T]: T[K] | null };

// Unions work differently in mapped types
type Result = Nullable<{ a: string } | { b: number }>;
// = { a: string | null } | { b: number | null }
// (mapped types preserve union structure)

Advanced: Distributive + Recursive

// Deep unwrap nested Promises
type DeepUnwrap<T> = T extends Promise<infer U>
  ? DeepUnwrap<U>
  : T;

type R1 = DeepUnwrap<Promise<Promise<Promise<string>>>>;
// = string

type R2 = DeepUnwrap<Promise<string> | Promise<Promise<number>>>;
// Distributes, then recursively unwraps each
// = string | number

// Flatten nested arrays (1 level)
type Flatten<T> = T extends (infer U)[] ? U : T;

type R3 = Flatten<string[] | number[][]>;
// = string | number[]

Quick Reference

| Pattern                           | Distributes? |
|-----------------------------------|--------------|
| T extends U ? X : Y               | Yes          |
| [T] extends [U] ? X : Y           | No           |
| T[] extends U[] ? X : Y           | No           |
| { p: T } extends { p: U } ? X : Y | No           |
| T & U (intersection, not conditional) | N/A      |

| Type Argument | Distribution Result      |
|---------------|--------------------------|
| string        | Single evaluation        |
| string | number | Evaluates each member |
| never         | Returns never            |
| unknown       | Single evaluation        |
| any           | Usually returns union    |

Key Takeaways

  • Conditional types distribute over unions when the checked type is a naked type parameter
  • Exclude, Extract, and NonNullable all rely on this behavior
  • Wrap in [T] to prevent distribution: [T] extends [U] ? X : Y
  • never is the empty union—distribution over it returns never
  • Use wrapped patterns to detect and handle never explicitly
  • Distribution + infer = powerful type extraction from union members

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Surface
TypeScript's satisfies Operator: Validate Without Widening
6 min
CSS
Deep
size-adjust: Eliminating Font Swap Layout Shifts
7 min

Advertisement