Skip to content

Recursive Types

Recursion at the type level looks exactly like recursion in code: a type alias that mentions itself. The classic example is a JSON value — data nested to any depth:

type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
const ok: Json = { name: "Ada", tags: ["x", "y"], meta: { n: 1 } }; // ✅
const bad: Json = { fn: () => 1 }; // ❌ functions are not Json

Json appears in its own definition twice — inside the array and inside the object — which is what lets it describe arbitrarily deep data. Recursive aliases like this are common and completely idiomatic.

The more powerful pattern is recursing over a tuple type, peeling one element at a time with infer — the type-level equivalent of processing a list head/tail.

// Reverse a tuple type
type Reverse<T extends unknown[]> =
T extends [infer Head, ...infer Rest]
? [...Reverse<Rest>, Head]
: [];
type R = Reverse<[1, 2, 3]>;
// ^? [3, 2, 1]
// Length of a tuple
type Length<T extends unknown[]> = T["length"];
type L = Length<[1, 2, 3]>; // 3

[infer Head, ...infer Rest] splits a tuple into its first element and the rest; the type calls itself on Rest until it hits the empty tuple base case. This is how typed path builders, deep Get<Obj, "a.b.c"> lookups, and tuple math are written.

flowchart LR
  a["[1, 2, 3]"] --> b["head 1, rest [2, 3]"]
  b --> c["head 2, rest [3]"]
  c --> d["head 3, rest []"]
  d --> e["base case: stop"]
Recursion peels one element per step until the base case

Type-level recursion is not unbounded. The compiler caps recursive instantiation to protect itself from infinite loops, and you will hit the ceiling on large inputs:

// Error on deep recursion:
// "Type instantiation is excessively deep and possibly infinite."

The historical limit was around 50 levels of naive recursion, later relaxed for tail-recursive conditional types (where the recursive call is the entire result), which can go much deeper — roughly a few thousand. Writing a recursion tail-recursively (accumulate into an extra type parameter, return it at the base case) is the standard trick to push the limit:

// Tail-recursive: accumulate results, so the compiler can optimize it
type BuildTuple<N extends number, Acc extends unknown[] = []> =
Acc["length"] extends N ? Acc : BuildTuple<N, [...Acc, unknown]>;

Recursive types are the sharpest tool in the type-level box, and the easiest to misuse. Before writing one, ask:

  • Will a teammate be able to read it? A clever type that no one dares touch is a liability, not an asset.
  • Is the error message tolerable? Deep recursive types produce dreadful errors when they fail — sometimes worse than the bug they prevent.
  • Would a simpler type or a runtime check do? Often the honest answer is a plain type plus validation at the boundary (the Practical Mastery module).

The best type-level engineers write less of it than they could, reserving recursion for the few cases — deep key paths, precise builder APIs — where it genuinely earns its keep.

What makes the `Json` type able to describe arbitrarily deep data?
What does `[infer Head, ...infer Rest]` do in a recursive tuple type?
Why does the compiler limit type instantiation depth?
What is the standard trick to push recursion deeper?