Conditional types: a type-level ternary keyed on assignability
Conditional types pick a branch by asking 'is T assignable to U?' — not by JS class inheritance. A naked type parameter distributes the check over each union member, and a generic T defers resolution until instantiation.
You crack open @types/node and find ReturnType, NonNullable, Exclude — each one a single line ending in ? X : Y. You hover Exclude<"a" | "b" | "c", "a"> and the tooltip says "b" | "c", even though Exclude was written for one type, not three. A ?: that loops over a union without a loop in sight. That is a conditional type, and the loop is a feature called distribution.
In the next fifteen minutes you’ll see exactly why that distribution triggers, when it doesn’t, and why a single misread of extends sends every senior engineer down the wrong mental model at least once.
extends here means “assignable to”, not “subclass of”
Why does this matter? Because when you misread extends as class inheritance, you start expecting that only narrower types pass — and then a wide object satisfying a narrow interface looks impossible. Once you see it as an assignability test, every utility type in the standard library becomes obvious.
A conditional type is a type-level ternary:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // ^? true
type B = IsString<number>; // ^? falseThe word extends is the same keyword classes use, and that mislead is the single biggest source of confusion. In a conditional type, T extends U asks one question: is every value of T also a valid value of U? — i.e. is T assignable to U? It has nothing to do with JS prototype chains or class X extends Y. Structural assignability (unit 01) is the whole test.
type T1 = { a: 1; b: 2 } extends { a: 1 } ? "yes" : "no"; // ^? "yes"
// a wider object IS assignable to a narrower one
type T2 = 42 extends number ? "yes" : "no"; // ^? "yes"
type T3 = string extends "a" ? "yes" : "no"; // ^? "no"
// string is wider than the literal "a", so NOT assignableNaked type parameters distribute over unions
Here is the behaviour that surprised you in the tooltip. When the type being checked is a naked type parameter — T appearing bare on the left of extends, not wrapped in anything — and you instantiate it with a union, TypeScript splits the union and applies the conditional to each member separately, then unions the results:
type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>;
// ^? string[] | number[]
// NOT (string | number)[] — the union was distributed:
// ToArray<string> | ToArray<number> => string[] | number[]T extends unknown ? always takes the true branch, so this is just “wrap in array” — but because T is naked, it runs once per union member. This is distributive conditional types. We meet the full treatment in unit 05; here, just internalise the trigger: naked type parameter + union argument.
A useful corollary: distributing over never yields never, because never is the empty union (zero members to iterate):
type Z = ToArray<never>; // ^? never — not never[]▸Why this works
Why distribute at all? The design intent is that conditional types behave like the union itself “asks the question”. Exclude<T, U> = T extends U ? never : T only works because each member of T is tested individually against U: matching members become never (and never vanishes from a union), non-matching members survive. Without distribution, Exclude<"a" | "b", "a"> would test the whole union "a" | "b" against "a", get false, and return "a" | "b" unchanged — useless.
Turning distribution off with a tuple wrapper
Sometimes you want to compare the union as one thing. Wrap both sides in a one-element tuple so T is no longer naked:
type IsExactlyStringOrNumber<T> =
[T] extends [string | number] ? true : false;
type Y = IsExactlyStringOrNumber<string | number>; // ^? true
// [string | number] extends [string | number] — checked as a whole, no split
type N = IsExactlyStringOrNumber<string | boolean>; // ^? falseThe [T] makes the parameter non-naked, so distribution is suppressed and the union is tested in one shot. This [T] extends [U] idiom is the canonical “don’t distribute” trick — you’ll see it everywhere in library type code.
Deferred conditionals: when T is still generic
If the conditional can’t yet decide — because T is an unresolved type parameter inside another generic — TypeScript defers the resolution. It keeps the conditional type symbolic until enough is known at instantiation:
function unwrap<T>(x: T): T extends Promise<infer U> ? U : T {
// inside the body, the return type is still the *unresolved* conditional:
// T extends Promise<infer U> ? U : T — TS can't evaluate it, T is open
throw new Error("impl elided");
}
const a = unwrap(Promise.resolve(5)); // ^? number — resolved at the call site
const b = unwrap("plain"); // ^? "plain"This deferral is what lets you model overload-like return types with one signature: the caller’s concrete T drives which branch fires. It also means a conditional over a generic T is lazy — assignability between two unresolved conditionals only holds when their checked/extends/branches match structurally, which is why generic conditional code sometimes refuses to simplify until you instantiate it.
The cost side: where a conditional stops being worth it
Distribution is also a combinatorial trap. The conditional runs once per union member, and if its branches build new unions, member counts multiply. TypeScript caps a union at 100,000 members and then bails with Expression produces a union type that is too complex to represent; a tuple that grows past its internal limit triggers the sibling Expression produces a tuple type that is too large to represent. You can hit the union cap from one over-eager distributive conditional fed a large keyof — and the failure shows up not as a clean type but as an any plus a red squiggle in unrelated call sites.
The senior breakpoint: a conditional type buys you a return type that tracks the input automatically, with zero runtime cost. The bills come due at compile time and in error legibility. When a branch goes wrong, the tooltip shows the whole unresolved conditional — T extends Promise<infer U> ? U : T extends ... nested four deep — instead of a name, and a junior reading the call site has no idea why the inferred type is never. Reach for the conditional when the type genuinely varies with the argument (the unwrap/Awaited case). When you only need one runtime decision, a discriminated union plus a plain if is cheaper to read, cheaper to compile, and produces an error message a teammate can act on. If the type and the value must be derived together (a router’s param map, an ORM’s row type), prefer a codegen step that emits flat, named types over a tower of distributive conditionals — the generated type Row = { id: number; ... } is something a human can hover and understand; the conditional that produced it is not.
▸lesson.inset.warning
Numbers worth memorising before you debug a slow editor. Conditional types are recursive — Awaited peels nested promises by re-entering itself. TypeScript permits roughly 1000 levels of tail-recursive conditional instantiation (the tail-recursion-elimination limit; non-tail recursion blows up far sooner, around an instantiation depth of 50, with Type instantiation is excessively deep and possibly infinite). When a type explodes, tsc --extendedDiagnostics is the instrument: watch Instantiation count and Check time. A single pathological recursive conditional can swing Check time from ~1s to 20s+ on a mid-size project and drop the editor’s IntelliSense from instant to multi-second per keystroke — the same type-checker runs in your editor, so a type that compiles slowly types slowly. If Instantiation count jumps into the millions for one file, a conditional (often distributive over a big union) is almost always the culprit.
Given type ToArray<T> = T extends unknown ? T[] : never, what is ToArray<string | number>?
Order the steps TypeScript takes to resolve Exclude<'a' | 'b', 'a'> where Exclude<T, U> = T extends U ? never : T:
- 1 See that T is a naked type parameter instantiated with the union 'a' | 'b'
- 2 Distribute: split into ('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b')
- 3 Resolve each: 'a' is assignable to 'a' -> never; 'b' is not -> 'b'
- 4 Union the branch results: never | 'b', and never drops out, leaving 'b'
- 01In a conditional type, what exactly does `T extends U` test, and why is reading it as class inheritance wrong?
- 02What is distribution, when does it trigger, and what does ToArray<string | number> resolve to for type ToArray<T> = T extends unknown ? T[] : never?
- 03What does it mean that a conditional type is 'deferred', and how does that enable overload-like return types?
You now read T extends U ? X : Y as “is T assignable to U?”, you know a naked type parameter distributes over unions while [T] suppresses it, and you know a generic conditional stays deferred until the call site. Next, the infer keyword plugs into the extends clause to capture a piece of the matched type — the mechanism behind ReturnType, Awaited, and tuple head/tail extraction — and we’ll see how the position of infer decides whether multiple matches collapse to a union or an intersection. Now when you see a utility type that trims a union or wraps each member separately, you’ll know to look for a naked T and a distributive conditional — not a loop, not magic.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.