TypeScript advanced types · 5 / 8
lesson 5

Recursive types

A type can refer to itself. JSON, deep paths, and how far the compiler will let you go.

~ 16 min read·lesson 5 of 8
0 / 8

A piece of JSON can hold strings, numbers, booleans, null, arrays of JSON, and objects of JSON. The definition refers to itself — JSON is one of these primitives, or a collection of more JSON. You can't write that with normal types unless types are allowed to mention themselves. They are. Once you accept that, a small set of recursive types lets you describe nested data, deep object paths, and even tiny string parsers — all at compile time.

A JSON value type

Start with the textbook example. A JSON value is one of seven things, three of which are JSON-shaped collections.

json.ts
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };

const ok: Json = {
user: { id: 1, tags: ["admin", "beta"] },
active: true,
meta: null,
};

const bad: Json = { fn: () => 1 };  // error — function isn't JSON

The two recursive arms are Json[] (an array of more Json) and { [key: string]: Json } (an object whose values are more Json). TypeScript handles this happily because the recursion is guarded — each step has to go through an array or object, so the type can't unfold infinitely without a real value to anchor it.

This single definition rejects shapes JSON can't carry. A function value, a Date, a Map — none match any of the seven arms, so they're flagged. Library authors lean on this when typing things like JSON.parse or HTTP response bodies.

check your understanding
Why doesn't this version work? type X = X | string;

Walking nested paths

Recursion at the type level lets you describe operations that walk a structure. A practical example: given an object type and a dot-separated path string, return the type sitting at that path.

get.ts
type Get<Obj, Path extends string> =
Path extends `${infer Head}.${infer Rest}`
  ? Head extends keyof Obj
    ? Get<Obj[Head], Rest>
    : never
  : Path extends keyof Obj
    ? Obj[Path]
    : never;

type User = { profile: { name: string; age: number }; id: string };

type A = Get<User, "profile.name">; // string
type B = Get<User, "profile.age">;  // number
type C = Get<User, "id">;           // string
type D = Get<User, "profile.x">;    // never

There are three branches. If the path has a dot, split into a head and rest; recurse into Obj[Head] with the rest. If the path is a single key, return the value at that key. If neither matches, the path doesn't exist on the object — give back never.

This kind of type powers form libraries (useForm({ name: "profile.name" }) knowing the value is a string), state-management libraries, and i18n key checkers. The recursion pattern is always the same: strip one layer off, recurse on the rest.

Get<User, "profile.name">Get<User["profile"], "name">User["profile"]["name"] resolves to: string
Each recursive call peels one segment off the path and looks up that key in the object type.

The depth limit

Recursive types aren't unlimited. The compiler caps how deep recursion can go to prevent runaway type-checking. The limit historically sat around 50 levels for general recursion. Tail-recursive types — where the recursive call is the last thing the conditional returns, with nothing wrapped around it — get a much higher limit (around 1000), which is why library authors carefully shape their recursions to be tail-recursive when paths can be long.

A non-tail recursion wraps the recursive call inside another type constructor.

non-tail.ts
// Non-tail: wraps the recursive call in a tuple
type Reverse<T extends any[]> =
T extends [infer Head, ...infer Rest]
  ? [...Reverse<Rest>, Head]
  : [];

The recursive call Reverse<Rest> is spread inside a tuple, so it's not in tail position. This works fine for short tuples but hits the depth cap on long ones.

tail.ts
// Tail-recursive: passes an accumulator through
type ReverseTail<T extends any[], Acc extends any[] = []> =
T extends [infer Head, ...infer Rest]
  ? ReverseTail<Rest, [Head, ...Acc]>
  : Acc;

The accumulator pattern moves the work into an extra parameter that grows on each call. The recursive call is now the last thing the conditional returns — pure tail recursion. The compiler optimizes it and lets you go much deeper.

Watch out

If a recursive type compiles for small inputs but errors on large ones with type instantiation is excessively deep, the recursion is non-tail. Refactor to use an accumulator, or accept the limit.

check your understanding
What's the practical reason library authors write tail-recursive types instead of natural-looking ones?

Try it yourself

check your understanding
Given the Json type from the lesson, which value passes the check?
check your understanding
Given Get from the lesson and type T = { a: { b: { c: number } } }, what is Get<T, "a.b.c">?
check your understanding
Why does type Reverse hit the depth cap on a 100-element tuple, while ReverseTail succeeds?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript advanced types
0 of 8 read