Recursive types
A type can refer to itself. JSON, deep paths, and how far the compiler will let you go.
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.
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 JSONThe 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.
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.
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">; // neverThere 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.
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: 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-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.
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.
Try it yourself
Json type from the lesson, which value passes the check?Get from the lesson and type T = { a: { b: { c: number } } }, what is Get<T, "a.b.c">?type Reverse hit the depth cap on a 100-element tuple, while ReverseTail succeeds?