Nota
Capire la distribuzione dei tipi condizionali e infer
Quando un tipo condizionale distribuisce su una union e quando no, come [T] extends [U] lo disattiva, quale posizione di infer vince, e perché la forma distributiva amplifica il costo.
Context
A utility type NonNullableKeys<T> worked in tests and then returned never
for a real type. The cause was distribution: the conditional was written over a
naked type parameter, the input was a union, and one member of that union
reduced the whole result to never. This note is the mechanics of when a
conditional type distributes, how to turn it off, how infer resolves when
there is more than one candidate, and why the distributive form is also the
expensive form. The cost angle continues in the companion note on the
type-instantiation budget.
Scope: conditional types, distribution, and infer in TypeScript 5.x
(re-checked on 5.6). Not mapped types beyond where they interact with
distribution, not template-literal type inference beyond infer in a template
position.
Question
- Exactly when does
T extends U ? X : Yiterate over the members of a unionT, and when does it treatTwhole? - What does wrapping both sides in a one-tuple (
[T] extends [U]) change, and what does it cost in readability? - With several
inferpositions for the same variable, which one wins, and does covariance vs contravariance change the answer? - Why does the distributive form show up as a hot spot in a
--generateTracerun?
Investigation
Mechanism, checked against small reproductions in the playground and against
--extendedDiagnostics Instantiations for the cost claims.
- The distribution rule. A conditional type is distributive when the type
being checked (the part before
extends) is a naked type parameter: a bareT, notT[], not[T], not{ x: T }, notPromise<T>. When it is naked and the argument is a union, the checker evaluates the conditional once per union member and unions the results. - The off switch. Any wrapper around the checked type stops distribution.
The idiom is
[T] extends [U] ? X : Y: the one-tuple makesTnon-naked, so the union is compared as a whole. neveris the empty union. Distributing overneverproducesnever(zero members, empty union). This is whyT extends any ? … : …withT = never"skips" the true branch: there is nothing to distribute over.infercandidates. A singleinfer Rcan appear in several positions. The checker collects a candidate from each and combines them: union for covariant positions (e.g. multiple return positions, multiple array element positions), intersection for contravariant positions (function parameters). Sameinfername in both kinds of position, and the contravariant combination generally dominates for the parameter side.
Observations
- Distribution is a property of syntax, not intent.
type F<T> = T extends string ? A : Bdistributes;type F<T> = [T] extends [string] ? A : Bdoes not. Nothing about the name or the union says which you meant; the presence or absence of a wrapper does. - Distribution changes the result, not just the performance. Over
T = "a" | "b", a distributiveT extends "a" ? 1 : 0is1 | 0; the non-distributive[T] extends ["a"] ? 1 : 0is0. Anevermember can collapse a distributive result; it cannot collapse a wrapped one. - Built-ins pick deliberately.
Exclude,Extract,NonNullableare distributive on purpose (they filter union members).Awaitedusesinferwith recursion. If you are writing a "does this whole type satisfy X" predicate, you almost always want the wrapped, non-distributive form. - The distributive form is the expensive form. Per-member evaluation means
a conditional over a union of N is N instantiations, and nesting multiplies.
In a
--generateTrace, an unwrapped conditional over a large string-literal union is a classic hot span. Measured in the companion lab (ts-typecheck-lab, TS 5.6.3): the distributive form'sInstantiationsis exactly2N + 1for a union of N (1 → 5000); the[U] extends […]wrapped form is flat at 6. Nesting a second distributive level multiplies:Instantiations / (N × M)stays ≈ 6.1 across the grid. inferin a rest/tuple position is how variadic inference works, and it is cheap relative to a distributive conditional. The cost problem is distribution, notinferitself.
Technical details
Naked vs wrapped, precisely. "Naked" means the type parameter is the entire
type in the checked position, with no surrounding constructor. T is naked;
T & {}, T[], [T], keyof T, T extends infer U ? … (the infer form)
are not. Only the naked form triggers per-member distribution. The wrapper does
not have to be a tuple; a tuple is the least intrusive choice, and T & unknown
also works but reads worse.
Distribution and boolean / any. boolean is true | false internally,
so a distributive conditional over boolean runs twice. any in the checked
position takes both branches and yields X | Y. never yields never (empty
distribution). These three are the usual "why did I get a union / never I
didn't expect" causes.
infer combination rules. For type P<T> = T extends { a: infer U; b: infer U } ? U : never, both positions are covariant (property types), so U is
T["a"] | T["b"]. For type Q<T> = T extends (a: infer U, b: infer U) => any ? U : never, both are contravariant (parameter positions), so U is T-param-a
& T-param-b. Mixed positions: the checker still collects all candidates;
the practical rule is "return-like positions union, parameter-like positions
intersect". Constrained infer (infer U extends string) filters candidates
and, since TS 4.8, can also act as a cast in the true branch.
Recursion and tail position (from the companion note, restated). A
conditional type may reference itself. TS 4.5+ evaluates a tail-position
recursive conditional iteratively, so type Trim<S> = S extends `${" "}${infer R}` ? Trim<R> : S is cheap and deep. Wrapping the recursive call ([...Trim<R>],
{ x: Trim<R> }, Trim<R> | X) puts it off tail position and it becomes
depth-limited and TS2589-prone.
Practical implications
- Decide distribution explicitly. "Filter members of a union" → naked
T. "Does the whole type satisfy this" →[T] extends [U]. Write the wrapper even when the current input happens not to be a union, because it is the input that changes later. - If a utility type mysteriously returns
neveror a wider union than expected, check for an unwrapped conditional and anever/boolean/anymember in the input. - For "which
inferwins" surprises, name the position: parameters intersect, returns/properties union. If you need one specific position, don't reuse theinfername across kinds. - Keep recursive conditional types in tail position. If you must build up a structure, accumulate in a type parameter that is itself in tail position rather than wrapping the recursive call.
- When a
--generateTracehot span is a conditional type, the first question is "is this distributing, and does it need to?". The wrapped form is often a free win, and behaviour-preserving if you didn't want distribution.
Limitations
- Mixed-variance
inferis underspecified in practice. The "parameters intersect, returns union" rule covers the common cases; exotic positions (athisparameter, aninferinside a mapped type inside the conditional) can surprise, and this note does not chart all of them. - "Tail position" is a checker-internal judgement. The examples here are clear cases; whether a given nested form counts as tail-recursive is ultimately what the 5.x checker does, and it has shifted across minors.
- Cost claims are
Instantiations-based, from small fixtures, not wall-clock on a real project. See the companion note's lab protocol. - Playground-checked reproductions. The behavioural claims are from minimal
repros on 5.6; a different
lib/strictsetting can change edge results, notably aroundanyandunknownin the checked position.
Takeaways
- A conditional type distributes over a union iff the checked type is a naked
type parameter; any wrapper (
[T] extends [U]is the idiom) turns it off. - Distribution changes the result, not only speed:
never/boolean/anymembers are the usual "unexpectedneveror union" causes. - Multiple
inferfor one variable: covariant positions union, contravariant (parameter) positions intersect. - The distributive form is also the expensive form, per-member instantiation and
multiplicative under nesting, so an unwrapped conditional over a large union is
a
--generateTracehot span you can often just wrap. - Keep recursive conditionals in tail position or they hit the TS2589 wall.
- typescript
- conditional-types
- distributive-conditional-types
- infer
- type-inference
- generics
- compiler-performance