Skip to content
C Codeloom
TypeScript

TypeScript Recursive Types Tutorial

Build recursive types in TypeScript: deep readonly, JSON, paths, and tuple manipulation. Learn how to write recursion that the compiler can actually evaluate.

·5 min read · By Codeloom
Advanced 9 min read

What you'll learn

  • How recursive type aliases work
  • Patterns for deep transformations like DeepReadonly
  • Recursing over tuples with rest elements
  • Limits: recursion depth and tail recursion
  • Practical examples: JSON, paths, and accumulators

Prerequisites

  • Comfortable with generics
  • Familiarity with conditional and mapped types

What and Why

A recursive type references itself in its own definition. TypeScript supports this for both object-shaped aliases and conditional types, letting you walk through nested structures at the type level. The result is types that match real data: trees, JSON, file paths, nested form values, and tuple algorithms.

Without recursion, you would have to enumerate every depth manually. With it, one definition handles arbitrarily nested shapes. The cost is compile time and a maximum recursion depth, so the goal is to keep recursion shallow, focused, and tail-friendly.

Mental Model

Treat a recursive type like a function that calls itself with a smaller input. Each recursive call must make progress toward a base case: an empty tuple, a primitive, or a fixed depth. If progress stalls, the compiler bails out with an “instantiation depth” error.

There are two flavors. Structural recursion walks an object or array shape, applying a transformation at each level. Tuple recursion peels elements off a tuple, often using [infer H, ...infer T]. Both share the same pattern: base case first, recursive case second.

Hands-on Example

Let us build a DeepReadonly that freezes every level of an object.

type DeepReadonly<T> =
  T extends (...args: any[]) => any ? T :
  T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
  T;

type Config = { user: { name: string; tags: string[] } };
type Frozen = DeepReadonly<Config>;
// { readonly user: { readonly name: string; readonly tags: readonly string[] } }

We special-case functions so we do not destroy callable signatures, then recurse through objects, and finally return primitives untouched. Arrays are objects, so the same branch handles them; TypeScript turns readonly arrays into readonly T[].

A second example: a JSON type.

type Json =
  | string | number | boolean | null
  | Json[]
  | { [k: string]: Json };

This is direct recursion through a union. Any value that matches one of the branches is valid JSON. You can use it as a constraint: function send<T extends Json>(body: T).

DeepReadonly<{ a: { b: number } }>
      |
      v
{ readonly a: DeepReadonly<{ b: number }> }
                     |
                     v
      { readonly b: DeepReadonly<number> }
                            |
                            v
                          number   <-- base case
      Result: { readonly a: { readonly b: number } }
Recursion unrolling for DeepReadonly

For tuple recursion, consider building a Reverse helper.

type Reverse<T extends unknown[], Acc extends unknown[] = []> =
  T extends [infer H, ...infer R] ? Reverse<R, [H, ...Acc]> : Acc;

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

The accumulator pattern keeps recursion tail-shaped, which TypeScript optimizes in recent versions. Each step peels the head, prepends it to the accumulator, and recurses on the rest.

Common Pitfalls

The first failure mode is unbounded recursion. If your conditional never reaches a base case, the compiler throws “Type instantiation is excessively deep.” Always check that recursive calls move toward a smaller input.

The second is structural cycles. If your input type contains a self-reference, naive recursion loops forever. Add a depth limit using a counter tuple: Acc['length'] extends 10 ? T : ....

A third issue is performance. Even valid recursion can slow editors to a crawl. Profile your types with --extendedDiagnostics and prefer iterative tuple patterns over deeply nested conditionals.

Watch out for unions in recursive positions. Distribution multiplies work and can blow up depth quickly. Wrap parameters in tuples ([T] extends [...]) when distribution is unwanted.

Finally, do not recurse through any. Once any enters the pipeline, it propagates everywhere, and your type collapses into a useless wildcard.

Best Practices

Write the base case first. It documents intent and prevents accidental infinite loops. Use never for invalid inputs so misuse fails loudly.

Prefer tail recursion with an accumulator. It is faster, deeper, and easier for the compiler to optimize. Reserve nested conditionals for cases where order of operations matters.

Cap depth explicitly when working with user-controlled shapes. A DeepReadonly for arbitrary configs should bottom out after, say, ten levels rather than risking editor freezes.

Compose small recursive helpers instead of building one giant alias. DeepPartial, DeepReadonly, and Paths are each simple; combining them gives you expressive power without unmanageable complexity.

Wrap-up

Recursive types let TypeScript follow the shape of real data instead of forcing you to enumerate every depth. The recipe is always the same: define a base case, define a smaller recursive call, and watch out for distribution and depth.

Use recursion sparingly. When it fits, it produces remarkably honest types. When it does not, a runtime helper or a flatter type is almost always clearer.