Section 3 · Type-Level Programming
Recursive Types and Knowing When to Stop
20 min3 min readCode playground
Lesson at a glance
Write recursive types for trees, JSON and deep transformations, understand the compiler’s depth limits, and decide when a clever type costs more than the bug it prevents.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Cartwheel’s product categories form a tree: Kitchen contains Cookware, which contains Pans. The API returns that tree as nested JSON, and the checkout’s settings screen edits a deeply nested config object. Both need types that refer to themselves. TypeScript handles that well, up to a point. This lesson covers how to write recursive types, where the limits are, and the judgment call this whole section has been building towards: when to stop.
Types that refer to themselves
A type alias may mention itself inside an object or array type:
type Category = { id: number; name: string; children: Category[] };
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };Category describes a tree of any depth. Json describes any value JSON.parse can return, which makes it a much better return type for a parser than any: it is honest, and it forces you to narrow before use.
Recursive type-level functions follow the same idea. Awaited<T>, built into the lib since TypeScript 4.5, unwraps promises until there are none left. A simplified version:
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
type Loaded = MyAwaited<Promise<Promise<Order>>>; // OrderEach step peels off one Promise and calls itself on what was inside. When the type is no longer a promise, the false branch returns it and the recursion ends.
Deep transformations
The mapped types from Mapped Types and Key Remapping are shallow. Recursion makes them deep:
type DeepReadonly<T> = T extends (...args: any[]) => any
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type FrozenCheckout = DeepReadonly<Checkout>;Each case is there for a reason. Primitives fall through unchanged. Objects are mapped, and each property is processed recursively. Functions get their own branch because a mapped type keeps properties but not call signatures: without the branch, an onSubmit handler would turn into an object you cannot call. Arrays need no special case here, because a homomorphic mapped type applied to an array produces a readonly array.
DeepPartial is the other one you will meet, typically for test fixtures and config overrides:
type DeepPartial<T> = T extends readonly unknown[]
? T
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
declare function checkoutFixture(overrides?: DeepPartial<Checkout>): Checkout;
checkoutFixture({ shipping: { address: { country: 'JO' } } });Here arrays are kept whole on purpose: a partial array of partial line items is rarely what a test means.
Paths, and the cost of cleverness
Here is the type people reach for when they want get(checkout, 'shipping.address.city') to be checked:
type Paths<T> = {
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
}[keyof T & string];
type CheckoutPath = Paths<{ shipping: { address: { city: string; country: string } }; totalCents: number }>;
// 'shipping' | 'shipping.address' | 'shipping.address.city' | 'shipping.address.country' | 'totalCents'It works, and on a small type it is delightful. It is also a mapped type, a conditional, a template literal and recursion in four lines, and its cost grows with every field and every level. On a real 40-field state object with arrays and dates in it, a type like this can produce thousands of members and make every keystroke in the editor slower.
TypeScript also has hard limits. Each nested instantiation counts, and after a few dozen levels of ordinary recursion you get Type instantiation is excessively deep and possibly infinite. Since TypeScript 4.5, a conditional type whose recursive call is in tail position (the whole result of a branch, as in MyAwaited) is optimised and can run about 1,000 iterations. There is no compiler flag to raise either limit.
Recursive types are the right tool for genuinely recursive data: trees, JSON, nested configs. For everything else, write the boring type first, and reach for the clever one only when a real bug justifies it.
In the exercise you will write MyAwaited, DeepReadonly and DeepPartial, and use the last one for a test fixture. That closes the type-level section. Section 4 turns to patterns: what experienced teams actually build with all of this.
Key takeaways
- A type alias can refer to itself, which is how you describe trees, JSON and other nested data.
- Recursive type-level functions such as
DeepReadonlyandDeepPartialneed explicit cases for functions and arrays, or they transform things you did not mean to touch. - TypeScript stops recursion after a few dozen nested levels, or about 1,000 for tail-recursive conditional types, with “Type instantiation is excessively deep”.
readonlyonly blocks property assignment; methods likeDate#setFullYearorMap#setstill mutate a deeply readonly value.- Before shipping a clever type, ask what bug it prevents, whether a teammate can read its errors, and what it costs the editor.