EduFlow

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.

  1. 1The type-level ternary
  2. 2Distribution over unions
  3. 3infer: capture part of a type
  4. 4Filtering keys by value type
  5. 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

TypeScript
type IsArray<T> = T extends readonly unknown[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<Order>;    // false

T 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):

TypeScript
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 }); // ok

The 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:

TypeScript
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:

TypeScript
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 variant

Each 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.

The union of processing, shipped and cancelled enters Exclude with cancelled. It is split into three members. Each member is tested against cancelled: processing and shipped are kept, cancelled becomes never. The results are joined into processing or shipped. A | B | C 'processing' ext C? → keep 'shipped' ext C? → keep 'cancelled' ext C? → never 'processing' | 'shipped'
A distributive conditional splits the union, runs per member, and re-joins

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:

TypeScript
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):

TypeScript
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:

TypeScript
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:

TypeScript
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 : Y picks X when T is assignable to U and Y otherwise; with a generic T, the choice waits until T is 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.
  • infer declares a type variable inside the extends clause and captures whatever matches it, which is how ReturnType and Awaited work.
  • Mapping each key to itself or never and then indexing with [keyof T] filters keys by the type of their value.

Further reading

Check your understanding

4 questions

Question 1 of 4

type ToArray<T> = T extends unknown ? T[] : never. What is ToArray<string | number>?

Question 2 of 4

Which type gives you the shipped variant of the Order union?

Question 3 of 4

What is R?

TypeScript
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type R = MyReturnType<(id: number) => Promise<Order>>;
Question 4 of 4

type IsString<T> = T extends string ? true : false. What is IsString<never>?

Practice

Code playground

Build Exclude, Extract and friends

Implement these with conditional types (do not use the built-in Exclude, Extract, ReturnType or Awaited):

  • MyExclude<T, U>: the members of T not assignable to U.
  • MyExtract<T, U>: the members of T assignable to U.
  • MyReturnType<F>: the return type of a function type.
  • UnwrapPromise<T>: the value inside a Promise, or T itself if it is not a promise.
  • OrderWithStatus<S>: the Order variant(s) whose status is S.
  • KeysOfType<T, V>: the keys of T whose values are assignable to V.
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order =
  | { id: number; status: 'processing' }
  | { id: number; status: 'shipped'; trackingNumber: string }
  | { id: number; status: 'delivered'; trackingNumber: string; deliveredAt: string }
  | { id: number; status: 'cancelled'; cancelReason: string }
  | { id: number; status: 'returned'; trackingNumber: string; deliveredAt: string; refundCents: number };
type Line = { sku: string; quantity: number; unitPriceCents: number; giftWrapped: boolean };
declare function loadOrder(id: number): Promise<Order>;

type MyExclude<T, U> = T;
type MyExtract<T, U> = T;
type MyReturnType<F> = unknown;
type UnwrapPromise<T> = T;

// The Order variant(s) whose status is S.
type OrderWithStatus<S extends OrderStatus> = Order;

// The keys of T whose values are assignable to V.
type KeysOfType<T, V> = keyof T;

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<MyExclude<OrderStatus, 'cancelled' | 'returned'>, 'processing' | 'shipped' | 'delivered'>>;
type T2 = Expect<Equal<MyExtract<OrderStatus, 'shipped' | 'refunded'>, 'shipped'>>;
type T3 = Expect<Equal<MyReturnType<typeof loadOrder>, Promise<Order>>>;
type T4 = Expect<Equal<MyReturnType<() => void>, void>>;
type T5 = Expect<Equal<UnwrapPromise<Promise<Order>>, Order>>;
type T6 = Expect<Equal<UnwrapPromise<number>, number>>;
type T7 = Expect<Equal<OrderWithStatus<'cancelled'>, { id: number; status: 'cancelled'; cancelReason: string }>>;
type T8 = Expect<Equal<OrderWithStatus<'delivered' | 'returned'>['deliveredAt'], string>>;
type T9 = Expect<Equal<KeysOfType<Line, number>, 'quantity' | 'unitPriceCents'>>;
type T10 = Expect<Equal<KeysOfType<Line, boolean>, 'giftWrapped'>>;

const shippedOnly: OrderWithStatus<'shipped'> = { id: 1042, status: 'shipped', trackingNumber: '1Z-999' };
console.log(shippedOnly.trackingNumber);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors: all ten type tests pass