Write ToArray<string | number> and you might expect (string | number)[]. You get string[] | number[] instead. The conditional type did not run once on the union you handed it. It ran twice, once per member, and unioned the answers. That hidden loop is one of the most useful behaviours in the type system. It is also one of the easiest to trip over.

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

type A = ToArray<string | number>;   // string[] | number[]
//                                      not (string | number)[]

The distribution rule

A conditional type T extends U ? X : Y distributes when the checked type T is a naked type parameter and the argument passed for it is a union. Naked means the parameter appears on its own on the left of extends, not wrapped in another constructor like a tuple, an array, or a Promise.

When both conditions hold, the compiler splits the union, evaluates the conditional separately for each member with T bound to that single member, and unions the results. ToArray<string | number> becomes ToArray<string> | ToArray<number>, which resolves to string[] | number[].

So the body of a distributive conditional always reasons about one member at a time. Inside T extends unknown ? T[] : never, T is never the whole union. It is whichever member the current iteration is on. The same mechanism that splits a union for a recursive type threads single elements through Two Sum and the other archive experiments. Distribution is the compiler peeling a union the way recursion peels a tuple.

The empty-union edge

A union with no members is never. Distributing a conditional over never runs the body zero times, and a union of zero results is never again. The conditional short-circuits before T is ever bound.

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

type W1 = Wrap<string>;   // [string]
type W2 = Wrap<never>;    // never  - body never runs

W2 is not [never]. That surprises people the first time. It is the same edge that makes NonNullable<never> resolve to never rather than something wrapped. If you are building a utility and a never input produces a suspiciously empty answer, distribution over the empty union is the usual cause. When you want the body to run, switch distribution off (next section).

The built-ins are this trick

Exclude, Extract, and NonNullable are distributive conditionals over a naked parameter, nothing more. Their entire implementation is the distribution rule:

type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type NonNullable<T> = T & {};   // modern form; older: T extends null | undefined ? never : T

Read Exclude member by member. For each member of T, ask whether it is assignable to U. If yes, replace it with never, which contributes nothing to the final union. If no, keep it. The result is T with everything matching U filtered out.

That filtering generalises. Any predicate you can express as a conditional becomes a union filter for free:

type Strings<T> = T extends string ? T : never;

type Mixed = 'a' | 42 | 'b' | true | 'c';
type S = Strings<Mixed>;   // "a" | "b" | "c"

Each non-string member maps to never and drops out. No recursion, no accumulator, just distribution doing the iteration for you. Strings is Extract<Mixed, string> written by hand.

Switching it off

Distribution fires only on a naked parameter. Wrap the parameter in a tuple on both sides of extends and the checked type is no longer naked, so the compiler compares the whole union as a single unit:

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

type D = IsString<string | number>;        // boolean  (true | false)
type N = IsStringWhole<string | number>;   // false

IsString<string | number> distributes: string passes (true), number fails (false), and the union of those is boolean. IsStringWhole wraps both sides in [...], so it asks one question. Is the whole union assignable to string? It answers false once.

The tuple wrapper is the standard way to write any predicate that must treat a union atomically. The classic case is IsNever:

// Wrong: distributes over never, so the body never runs.
type IsNeverBroken<T> = T extends never ? true : false;
type B = IsNeverBroken<never>;            // never, not true

// Right: [never] is a non-empty tuple, so no distribution.
type IsNever<T> = [T] extends [never] ? true : false;
type Y = IsNever<never>;                  // true
type X = IsNever<string>;                 // false

IsNeverBroken<never> distributes over the empty union and collapses to never, exactly the edge from earlier. Wrapping in [T] extends [never] stops the split. [never] is a one-element tuple, the comparison runs, and you get the true you wanted. Equality and emptiness checks at the type level almost always need this bracket.

The boolean gotcha

boolean is not an atom. It is the union true | false. A distributive conditional sees two members where you might think there is one, and the results can look wrong until you remember that.

type IsTrue<T> = T extends true ? 'yes' : 'no';

type R = IsTrue<boolean>;   // "yes" | "no"

IsTrue<boolean> distributes into IsTrue<true> | IsTrue<false>, giving 'yes' | 'no' rather than a single verdict. Anything that branches on a boolean through a naked parameter splits in two. If you want one answer for the boolean type as a whole, bracket it:

type IsBoolean<T> = [T] extends [boolean] ? true : false;

type One = IsBoolean<boolean>;   // true   - compared as a unit

The same splitting bites when a generic flag flows into a conditional and you assumed it was a single true or false. Constrain it with extends boolean and it still arrives as a union at the call site IsTrue<boolean>. Only the tuple wrap collapses it.

Knowing which mode you are in

Two questions tell you everything. Is the checked type a bare parameter, and is the argument a union? Both yes means the conditional loops member by member and unions the results, which is what you want for filters like Exclude and Extract. Either no, a wrapped parameter or a non-union argument, means it runs once on the whole type, which is what you want for equality, emptiness, and boolean checks. The tuple wrapper is the switch between the two, and it costs one pair of brackets.

Next: how infer reaches inside those same conditionals to pull types apart, in The Many Faces of infer.