Section 3 · Type-Level Programming
Conditional Types, Distribution and infer
22 min4 min readCode playground
Lesson at a glance
Write conditional types that choose between types, predict how they distribute over unions, and pull types out of other types with infer, re-implementing Exclude, Extract and ReturnType.
- 1The type-level ternary
- 2Distribution over unions
- 3infer: capture part of a type
- 4Filtering keys by value type
- 5Conditional return types: handle with care
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Back in Designing a Typed API Client, routes with no input forced callers to write call('GET /me', {}). The fix needs a type that makes a decision: “if this route’s input has no required fields, make the argument optional; otherwise, require it”. Mapped types loop. Conditional types decide.
The type-level ternary
type IsArray<T> = T extends readonly unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<Order>; // falseT extends U ? X : Y reads like a ternary: if T is assignable to U, the result is X, otherwise Y. The extends here is the same assignability check as everywhere else in TypeScript, “is T a subset of U?”. When T is a generic parameter that is not known yet, TypeScript defers the conditional and keeps it as is until a concrete type arrives.
Here is the API client fix. {} extends Input is true exactly when every property of Input is optional (an empty object would satisfy it):
type CallArgs<R extends keyof Routes> = {} extends Routes[R]['input']
? [input?: Routes[R]['input']]
: [input: Routes[R]['input']];
declare function call<R extends keyof Routes>(route: R, ...args: CallArgs<R>): Promise<Routes[R]['output']>;
call('GET /me'); // ok: input is {}
call('GET /orders'); // ok: every filter is optional
call('GET /orders/:id'); // Error: Expected 2 arguments, but got 1.
call('GET /orders/:id', { id: 1 }); // okThe conditional produces a tuple type, and the rest parameter ...args spreads it into the parameter list. Labeled tuple elements (input?:) keep the parameter name in tooltips. Not one call site changed.
Distribution over unions
Conditional types have one behaviour that surprises everyone. When the checked type is a naked type parameter and you pass it a union, the conditional runs once per member and the results are joined:
type ToArray<T> = T extends unknown ? T[] : never;
type X = ToArray<string | number>; // string[] | number[], not (string | number)[]This is called distribution, and it is the engine behind several utility types. Here are Exclude and Extract, exactly as the lib defines them:
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type Active = MyExclude<OrderStatus, 'cancelled' | 'returned'>; // 'processing' | 'shipped' | 'delivered'
type Shipped = MyExtract<Order, { status: 'shipped' }>; // the ShippedOrder variantEach member goes through the conditional on its own. Members mapped to never vanish, because never is the empty union. MyExtract<Order, { status: 'shipped' }> is the cleanest way to name one variant of a discriminated union.
To turn distribution off, wrap both sides in a one-element tuple. [T] is not a naked type parameter, so the union is checked as a whole:
type ToArrayWhole<T> = [T] extends [unknown] ? T[] : never;
type Y = ToArrayWhole<string | number>; // (string | number)[]infer: capture part of a type
Inside the extends clause, infer X declares a type variable that captures whatever sits in that position, if the pattern matches. This is essentially how the lib defines ReturnType (the lib version also constrains F to a function type and falls back to any):
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type Loaded = MyReturnType<typeof loadOrder>; // Promise<Order>The same trick pulls types out of anything with structure:
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;infer can also carry a constraint, as in infer S extends string, which only matches when the captured type is a string and gives you S already narrowed. You will need that in the next lesson, when you capture pieces of string literal types.
Filtering keys by value type
Combine a mapped type, a conditional and an indexed access, and you can select keys by what their values are:
type KeysOfType<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];
type Line = { sku: string; quantity: number; unitPriceCents: number };
type NumericField = KeysOfType<Line, number>; // 'quantity' | 'unitPriceCents'The mapped type turns each property into its own key name or never, and [keyof T] collects the values into a union, where the nevers disappear. It reads backwards the first time; write it once, name it well, and reuse it. A sort function typed sortBy(lines, key: KeysOfType<Line, number>) now refuses to sort by sku.
Conditional return types: handle with care
It is tempting to give a function a conditional return type, such as function format<T extends number | number[]>(x: T): T extends number ? string : string[]. Callers get precise types, but the implementation does not: inside the function, T is still unknown, the conditional stays deferred, and TypeScript cannot check that return x.toFixed(2) matches it. You end up writing as any or as T extends number ? string : string[] on every return, which means the most delicate code in the function is the code nobody checks.
That is why the previous section preferred overloads or separate functions for this job. Conditional types shine in derived types (CallArgs, Extract, KeysOfType) where nothing has to be implemented against them, and they are weakest as the declared return type of hand-written logic.
In the exercise you will re-implement Exclude, Extract and ReturnType and use them on Cartwheel’s order types. The next lesson applies conditionals and infer to strings.
Key takeaways
T extends U ? X : YpicksXwhenTis assignable toUandYotherwise; with a genericT, the choice waits untilTis known.- A conditional on a naked type parameter distributes over unions, running once per member and joining the results.
- Wrap both sides in brackets,
[T] extends [U], to test the union as a whole instead of distributing. inferdeclares a type variable inside theextendsclause and captures whatever matches it, which is howReturnTypeandAwaitedwork.- Mapping each key to itself or
neverand then indexing with[keyof T]filters keys by the type of their value.