open atlas
↑ Back to track
TypeScript type system, deep TS · 05 · 01

Recursion in types

A recursive conditional type peels one element off a tuple, recurses on the rest, and stops at a base case — like a runtime recursion, but the compiler is the interpreter and depth is finite.

TS Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

You are writing the types for a tiny router library. The path "/users/:id/posts/:slug" should yield { id: string; slug: string } — automatically, at compile time, with no codegen step. To get there you need the type system to walk a structure one element at a time and accumulate results. That is recursion, and the compiler is your interpreter. It will happily run your type program — right up until it prints Type instantiation is excessively deep and possibly infinite. and gives up. Knowing where that wall sits, and how TypeScript 4.5 moved it, is the difference between a clever type and a shipped one.

In ten minutes you will know exactly why the compiler gives up and how to push that wall from 50 levels to 1000.

The shape of every recursive type

A recursive conditional type has exactly two arms, just like a well-formed recursive function:

  • a base case that returns a concrete result without recursing, and
  • a recursive case that does a little work and refers to itself on a smaller input.

The “smaller input” is almost always a tuple with its head split off. The real lever is [infer Head, ...infer Tail]. In a conditional type’s extends clause this pattern matches a non-empty tuple, binds the first element to Head and everything after it to Tail. The base case is the empty tuple [], which fails the extends check and falls through to the else arm.

Reverse: the canonical walk

type Reverse<T extends readonly unknown[]> =
  T extends readonly [infer Head, ...infer Tail]
    ? [...Reverse<Tail>, Head]
    : [];

type R = Reverse<[1, 2, 3]>;
//   ^? type R = [3, 2, 1]

Read it as a trace. Reverse<[1,2,3]> matches, Head = 1, Tail = [2,3], so it becomes [...Reverse<[2,3]>, 1]. That recurses to [...Reverse<[3]>, 2, 1], then [...Reverse<[]>, 3, 2, 1], and Reverse<[]> hits the base case []. The accumulated spreads collapse to [3, 2, 1].

Join and Includes — same skeleton, different accumulation

type Join<T extends readonly string[], D extends string> =
  T extends readonly [infer H extends string, ...infer R extends string[]]
    ? R extends [] ? H : `${H}${D}${Join<R, D>}`
    : "";

type J = Join<["a", "b", "c"], "-">;
//   ^? type J = "a-b-c"

type Includes<T extends readonly unknown[], U> =
  T extends readonly [infer H, ...infer R]
    ? [H] extends [U] ? [U] extends [H] ? true : Includes<R, U> : Includes<R, U>
    : false;

type Inc = Includes<[1, 2, 3], 2>;
//   ^? type Inc = true

Join carries the separator D along the recursion and special-cases the last element (R extends []) so the string does not end with a stray delimiter. Includes short-circuits the moment it finds a match — the recursive call only happens in the “not yet found” branch. (Note the [H] extends [U] and [U] extends [H] wrapping — that opts out of distribution so the comparison is exact; you will meet that trick in the next lesson.)

Why this works

Why infer H extends string? The constraint on H lets you use H in a template literal ${H} immediately, without a second conditional to prove it is a string. Constrained infer (TS 4.7+) is how you keep recursive string types readable instead of nesting H extends string ? ... : never at every step.

The depth wall — and why tail position matters

When you hit “Type instantiation is excessively deep and possibly infinite.”, the question is whether you have a wrong base case or simply a non-tail call — the same error message covers both, and the fix is different for each. The compiler bounds recursion to keep type checking from hanging. Historically, a recursive conditional type blew up around 50 levels of self-reference with:

type BuildBad<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? Acc : [unknown, ...BuildBad<N, [unknown, ...Acc]>];
//                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Error: Type instantiation is excessively deep and possibly infinite.
// (the recursive call is wrapped in [unknown, ...] — NOT in tail position)

TypeScript 4.5 added tail-recursion elimination on conditional types. When the recursive call is the entire result of a conditional branch — nothing wrapping it — the compiler reuses the evaluation frame instead of nesting, and the effective limit jumps to roughly 1000. The fix is to thread an accumulator and return it directly:

type Build<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N
    ? Acc                                     // base case
    : Build<N, [unknown, ...Acc]>;            // TAIL call: the whole branch IS the recursion

type T999 = Build<999>["length"];
//   ^? type T999 = 999   (no error — tail-recursion elimination)

The structural difference: [unknown, ...Build<...>] wraps the call, so each frame must wait for the inner result — not eliminable. Build<N, [unknown, ...Acc]> is the result — eliminable. Restructure your recursion so the self-call sits in tail position and the wall moves from 50 to 1000.

Quiz

Why does `type Bad<T> = T extends [infer H, ...infer R] ? [Bad<R>, H] : []` hit the depth limit far sooner than a tail-recursive version?

Order the steps

Order the evaluation of Reverse<[1, 2, 3]> from first match to final collapsed result.

  1. 1 Reverse<[1,2,3]> matches, Head=1, Tail=[2,3] -> [...Reverse<[2,3]>, 1]
  2. 2 Reverse<[2,3]> matches, Head=2, Tail=[3] -> [...Reverse<[3]>, 2, 1]
  3. 3 Reverse<[3]> matches, Head=3, Tail=[] -> [...Reverse<[]>, 3, 2, 1]
  4. 4 Reverse<[]> hits the base case and returns []
  5. 5 Spreads collapse to the final type [3, 2, 1]
Recall before you leave
  1. 01
    What are the two arms every well-formed recursive conditional type must have, and what does the [infer Head, ...infer Tail] pattern do in the recursive arm?
  2. 02
    What is tail-recursion elimination on conditional types, when does it apply, and how does it change the depth limit?
  3. 03
    What exactly causes the error 'Type instantiation is excessively deep and possibly infinite.' and what are the two distinct root causes to check?
Recap

A recursive type is a recursive function the compiler runs: a base case plus a recursive case that peels [infer Head, ...infer Tail] and self-calls on the shorter tuple. Reverse, Join, and Includes share that skeleton, differing only in what they accumulate. The compiler bounds depth: a non-tail call walls out around 50 with “Type instantiation is excessively deep and possibly infinite.”, but TypeScript 4.5 tail-recursion elimination lifts that to ~1000 when the self-call is the whole branch. Now when you see that error, your first move is to check the base case, then check whether the self-call is wrapped — those two questions cover every case.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.