EduFlow

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.

  1. 1Types that refer to themselves
  2. 2Deep transformations
  3. 3Paths, and the cost of cleverness

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:

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

TypeScript
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;

type Loaded = MyAwaited<Promise<Promise<Order>>>; // Order

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

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

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

TypeScript
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 DeepReadonly and DeepPartial need 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”.
  • readonly only blocks property assignment; methods like Date#setFullYear or Map#set still 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

With this definition, what is the type of o.lines?

TypeScript
type DeepReadonly<T> = T extends (...args: any[]) => any
  ? T
  : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
declare const o: DeepReadonly<{ lines: { sku: string }[] }>;
Question 2 of 4

Why does a DeepReadonly type usually need a separate branch for functions?

Question 3 of 4

MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T. What is MyAwaited<Promise<Promise<Order>>>?

Question 4 of 4

A teammate adds a Paths<T> type that generates every dot path of the 40-field checkout state, and the editor becomes sluggish. What is the most pragmatic fix?

Practice

Code playground

Go deep, carefully

Implement three recursive types (do not use the built-in Awaited):

  • MyAwaited<T>: unwrap nested promises until the type is not a promise.
  • DeepReadonly<T>: make every property at every level readonly, turn arrays into readonly arrays of deeply readonly elements, and leave functions untouched (still callable).
  • DeepPartial<T>: make every property at every level optional, but keep arrays as they are (a fixture replaces a whole array).

checkoutFixture already merges overrides at runtime; once DeepPartial is right, the nested override in the example compiles.

type Checkout = {
  id: number;
  customer: { email: string; country: string };
  shipping: { method: 'standard' | 'express'; address: { line1: string; city: string } };
  lines: { sku: string; quantity: number }[];
  onSubmit: () => void;
};

type MyAwaited<T> = T;
type DeepReadonly<T> = Readonly<T>;
type DeepPartial<T> = Partial<T>;

const defaultCheckout: Checkout = {
  id: 1042,
  customer: { email: 'mona@example.com', country: 'EG' },
  shipping: { method: 'standard', address: { line1: '12 Nile St', city: 'Cairo' } },
  lines: [{ sku: 'TENT-2P', quantity: 1 }],
  onSubmit: () => {},
};

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function mergeDeep(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = { ...base };
  for (const [key, value] of Object.entries(overrides)) {
    if (value === undefined) continue;
    const current = out[key];
    out[key] = isPlainObject(current) && isPlainObject(value) ? mergeDeep(current, value) : value;
  }
  return out;
}

function checkoutFixture(overrides: DeepPartial<Checkout> = {}): Checkout {
  // mergeDeep works on untyped records; the result has Checkout's shape by construction.
  return mergeDeep(defaultCheckout, overrides as Record<string, unknown>) as Checkout;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<MyAwaited<Promise<Promise<Checkout>>>, Checkout>>;
type T2 = Expect<Equal<MyAwaited<string>, string>>;

type Frozen = DeepReadonly<Checkout>;
type T3 = Expect<Equal<Frozen['shipping']['address'], { readonly line1: string; readonly city: string }>>;
type T4 = Expect<Equal<Frozen['lines'], readonly { readonly sku: string; readonly quantity: number }[]>>;
type T5 = Expect<Equal<Frozen['onSubmit'], () => void>>;

type T6 = Expect<Equal<DeepPartial<Checkout>['shipping'], { method?: 'standard' | 'express'; address?: { line1?: string; city?: string } } | undefined>>;
type T7 = Expect<Equal<DeepPartial<Checkout>['lines'], { sku: string; quantity: number }[] | undefined>>;

function mutationTest(frozen: Frozen) {
  // @ts-expect-error: nested properties are readonly
  frozen.shipping.address.city = 'Amman';
}

const fixture = checkoutFixture({ shipping: { address: { city: 'Amman' } }, customer: { country: 'JO' } });
console.log(fixture.shipping.address, fixture.customer);
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, including all type tests
  • checkoutFixture merges nested overrides into the defaults