| |

TypeScript 43 🔷 Recursive Types

Some data has no natural bottom. A JSON value contains arrays and objects, which contain more JSON values, which contain more arrays and objects. A file tree contains directories, which contain files and more directories. A linked list node points to the next node, which points to the next, until one points to nothing. These shapes are recursive, and modeling them in TypeScript requires a type that refers to itself. TypeScript allows this directly — a type alias or interface can mention itself in its own definition — but the rules around recursion are subtle. Some recursive types are fine, some produce errors, some compile but hurt performance, and some are rejected because they cannot be resolved. This chapter covers how recursive types work, the difference between interface recursion and type alias recursion, mutually recursive types, recursive conditional types, depth limits, and the practical patterns for trees, linked lists, JSON, and deeply nested utility types.

Key point: A recursive type is a type whose definition refers to itself, directly or through another type. Interfaces and type aliases can both be recursive, but they behave differently: an interface can reference itself freely because its shape is resolved lazily, while a type alias must be deferred in some position — behind an object property, an array, a function, or a conditional — to avoid a circular reference error. TypeScript imposes a recursion depth limit (roughly 50 for conditional types, higher for structural recursion) that catches infinite expansion. Recursive conditional types make it possible to write type-level loops, which is how utility types like DeepPartial and Awaited are implemented.


What recursion means at the type level

A recursive type is one whose definition mentions itself. The simplest example is a tree node: it has a value and a list of children, each of which is itself a tree node.

interface TreeNode {
  value: number;
  children: TreeNode[];
}

This is valid TypeScript. TreeNode refers to itself inside the array type. The compiler does not try to expand TreeNode infinitely; it records the shape as “an object with a value and a children array of TreeNodes” and resolves members on demand.

Why lazy resolution matters. If TypeScript eagerly expanded every recursive type, TreeNode would become { value: number; children: { value: number; children: { ... } }[] } forever. Instead, the type is stored as a reference, and the compiler unfolds it one level at a time when it needs to check an access. This is the same approach used in most type systems that support recursion.

Recursion through other types. The recursion does not have to be direct. A can refer to B, which refers to A. This is called mutual recursion and is common in ASTs, expression grammars, and state machines.

interface Expression {
  kind: "binary" | "literal";
  left?: Expression;
  right?: Expression;
  value?: number;
}

Here Expression refers to itself through optional properties. The shape is finite at each level, and the recursion is resolved on access.

Why the compiler does not loop forever. TypeScript tracks recursive references and unfolds them only as far as needed to check a specific operation. Reading node.children[0].value unfolds two levels; it does not unfold the whole tree. This is why recursive types remain practical even for deep structures.


Interface recursion vs type alias recursion

Both interface and type can be recursive, but they are not equally permissive. Interfaces are the more forgiving form because the compiler can resolve them lazily by name. Type aliases are inlined and must be deferred to avoid a circular reference error.

// Interface — self-reference is fine
interface NodeA {
  next: NodeA | null;
}

// Type alias — direct self-reference errors
type NodeB = {
  next: NodeB | null;
}; // ✅ this actually works because the reference is inside an object

// But this errors:
// type Loop = Loop;
// type AlsoLoop = { x: Loop } extends { x: infer U } ? U : never;

The distinction is positional. A type alias can refer to itself as long as the reference is “under” a deferring construct — an object type, an array type, a function type, a union member, or a conditional type’s branch. A bare type Loop = Loop has no deferring construct, so the compiler detects a circular reference and reports an error.

Why interfaces are more permissive. An interface creates a named entity that can be referenced before it is resolved. Its members are looked up lazily, so interface Node { next: Node } is stored as “a type named Node with a next property of type Node” — a record, not an expansion. A type alias is closer to a macro: it stands for its right-hand side, so referring to itself without a deferring construct is like writing Loop = Loop on a whiteboard.

Practical guidance. Use interface for recursive object shapes (trees, linked lists, ASTs) because it is the natural fit and avoids deferral concerns. Use type when you need unions, intersections, or conditional types, and ensure any self-reference sits behind a deferring construct.


Recursive data structures in practice

The most common recursive structures are trees, linked lists, and JSON. Each has a slightly different shape.

Linked list. A node holds a value and a reference to the next node, or null to terminate.

interface ListNode<T> {
  value: T;
  next: ListNode<T> | null;
}

const list: ListNode<number> = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: null,
    },
  },
};

The | null is what terminates the recursion. Without it, the type would require an infinite chain, which is impossible to construct. The union with null (or undefined) is the standard way to express “the recursion ends here.”

Tree. A node holds a value and an array of child nodes. The empty array terminates a branch.

interface Tree<T> {
  value: T;
  children: Tree<T>[];
}

Arrays are naturally terminating: an empty array means no children, and the recursion stops.

JSON. A JSON value is one of a primitive, an array of JSON values, or an object with string keys and JSON values.

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

This is a recursive type alias with three deferring constructs: the array Json[], the object index signature { [key: string]: Json }, and the union itself. It compiles because none of the self-references is bare.

Why these patterns generalize. Every recursive data structure has two parts: a base case (the terminating value — null, an empty array, a primitive) and a recursive case (a value that contains more of the same structure). Typing them means writing both parts as a union, with the recursive case deferred inside a container.


Mutually recursive types

Two or more types can refer to each other. This is common in expression trees, grammars, and state machines.

interface Person {
  name: string;
  pets: Pet[];
}

interface Pet {
  name: string;
  owner: Person;
}

Person refers to Pet, and Pet refers back to Person. TypeScript resolves this without issue because both are interfaces and the references are inside object types. The compiler does not need to fully resolve one before resolving the other; it records both shapes and resolves members as needed.

Mutual recursion with type aliases. The same pattern with type works as long as each reference is deferred. If either reference is bare, the compiler reports a circular reference.

type Person = {
  name: string;
  pets: Pet[];
};

type Pet = {
  name: string;
  owner: Person;
};

This compiles because the references are inside object types. The deferral requirement is satisfied.

Expression grammars are a classic mutual-recursion case. An Expression contains Terms, and a Term contains Factors, and a Factor can contain a parenthesized Expression. Each level refers to the next, and the deepest refers back to the first.


Recursive conditional types

Conditional types can recurse, which lets you write type-level loops. This is how utility types like Awaited<T> and DeepPartial<T> are implemented.

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

DeepPartial unwraps one level of an object, applies itself to each property, and recurses until it hits a non-object. This is a recursive conditional type. It is legal because the recursive call is inside a mapped type, which defers the evaluation.

Unwrapping promises. Awaited<T> recursively unwraps nested promises.

type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;

type A = Unwrap<Promise<Promise<number>>>; // number

Each extends check peels one layer of Promise, and the recursive call handles the rest. The base case is the branch where T is not a promise.

Recursion depth limits. TypeScript imposes a limit on how deep a conditional type can recurse. The limit is implementation-defined and has changed across versions — historically around 50 instantiations, later raised. When the limit is exceeded, the compiler reports “Type instantiation is excessively deep and possibly infinite.” This is a safety valve against types that would otherwise expand forever.

type Infinite<T> = T extends any ? Infinite<T> : never; // ❌ error

The recursion here never terminates, so the compiler rejects it after the depth limit. Terminating recursion — where each step makes progress toward a base case — is fine. Non-terminating recursion is caught.

Why the limit exists. TypeScript’s type checker must terminate, and recursive conditional types can be expensive. The limit is a tradeoff between expressiveness and checker performance. For most real types, the recursion terminates in a handful of steps and the limit is never reached. For pathological cases, the limit prevents the compiler from hanging.

Why recursion in types is not the same as recursion in values. At runtime, recursion is controlled by the call stack and terminates when a base case is hit. At the type level, recursion is controlled by the checker’s instantiation budget and terminates when the type stops changing. A type that never reaches a base case is a type-level infinite loop, and the compiler catches it the same way a linter catches an infinite while (true).


Performance and practical limits

Recursive types are not free. Each level of unfolding costs the checker time, and deeply recursive types applied to large object types can slow compilation noticeably.

Costs of recursion:

  • Each instantiation of a recursive conditional type adds to the checker’s work
  • Deeply nested objects multiply the cost
  • Distributive conditional types over unions can expand combinatorially
  • Type display in editors can become unreadable when a type unfolds many levels

Mitigations:

  • Keep recursion depth to what the domain actually needs
  • Use interface for structural recursion, since it is resolved lazily
  • Avoid recursive conditional types on large unions
  • Break deep recursion into named intermediate types to help the checker cache
  • Prefer explicit depth limits when the data is bounded (e.g., a fixed-depth tree)

When recursion is the wrong tool. If the data has a known maximum depth — a configuration that is at most three levels deep — writing the type out explicitly is faster and clearer than a recursive type. Recursion is for genuinely unbounded structures.


Complete Example Session

// ============================================
// PART 1: LINKED LIST
// ============================================

interface ListNode<T> {
  value: T;
  next: ListNode<T> | null;
}

const list: ListNode<number> = {
  value: 1,
  next: { value: 2, next: { value: 3, next: null } },
};

function listLength<T>(node: ListNode<T> | null): number {
  return node === null ? 0 : 1 + listLength(node.next);
}

console.log(listLength(list)); // 3

// ============================================
// PART 2: TREE
// ============================================

interface Tree<T> {
  value: T;
  children: Tree<T>[];
}

const tree: Tree<string> = {
  value: "root",
  children: [
    { value: "a", children: [] },
    { value: "b", children: [{ value: "b1", children: [] }] },
  ],
};

function countNodes<T>(t: Tree<T>): number {
  return 1 + t.children.reduce((sum, c) => sum + countNodes(c), 0);
}

console.log(countNodes(tree)); // 4

// ============================================
// PART 3: JSON
// ============================================

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

const data: Json = {
  name: "Alice",
  age: 30,
  tags: ["admin", "user"],
  meta: { active: true, notes: null },
};

// ============================================
// PART 4: MUTUAL RECURSION
// ============================================

interface Person {
  name: string;
  pets: Pet[];
}

interface Pet {
  name: string;
  owner: Person;
}

// ============================================
// PART 5: RECURSIVE CONDITIONAL TYPE
// ============================================

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

interface Config {
  server: {
    host: string;
    port: number;
    ssl: { enabled: boolean; cert: string };
  };
}

const partial: DeepPartial<Config> = {
  server: { ssl: { enabled: true } },
};

// ============================================
// PART 6: AWAITED
// ============================================

type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;

type A = Unwrap<Promise<Promise<string>>>; // string
type B = Unwrap<number>;                    // number

// ============================================
// PART 7: DEPTH LIMIT
// ============================================

// type Infinite<T> = T extends any ? Infinite<T> : never;
// ❌ Type instantiation is excessively deep and possibly infinite

// ============================================
// PART 8: TUPLE RECURSION
// ============================================

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

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

// ============================================
// PART 9: FLATTEN
// ============================================

type Flatten<T> = T extends readonly (infer U)[]
  ? U extends readonly unknown[]
    ? Flatten<U>
    : U
  : T;

type F = Flatten<number[][][]>; // number

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// type Loop = Loop; // ❌ circular reference

// type BadJson = string | number | BadJson[] | { [k: string]: BadJson } | BadJson;
// The bare BadJson in the union is fine; a bare type Loop = Loop is not.

// Recursive conditional type with no base case — rejected at depth limit

Each part demonstrates a recursive type pattern. Parts 1 through 4 are structural recursion, parts 5 through 9 are recursive conditional types, and part 10 names the error cases.


Quick Reference

Recursion Forms

FormExampleNotes
Interface self-referenceinterface N { next: N }Always allowed
Type alias with deferraltype J = J[] | ...Reference must be deferred
Bare type aliastype L = L❌ circular reference
Mutual recursionA → B → ABoth must be interfaces or deferred aliases
Recursive conditionalT extends U ? F<T> : XDepth-limited

Deferring Constructs for Type Aliases

ConstructExample
Object property{ next: T }
ArrayT[]
Tuple[T]
Function() => T
Union memberT | string
Mapped type{ [K in keyof T]: F<T[K]> }
Conditional branchT extends U ? F<T> : X

Common Recursive Utility Types

TypePurpose
DeepPartial<T>Make all levels optional
DeepReadonly<T>Make all levels readonly
Awaited<T>Unwrap nested promises
Reverse<T>Reverse a tuple
Flatten<T>Flatten nested arrays
UnionToIntersection<T>Convert union to intersection

Limits and Errors

ErrorCause
“Circularly references itself”Bare type alias self-reference
“Type instantiation is excessively deep”Recursion exceeds depth limit
“Type alias ‘X’ circularly references itself”Missing deferral construct

When to Use Recursion

SituationRecursive?
Unbounded tree✅
Linked list✅
JSON value✅
AST✅
Fixed-depth config (3 levels)❌ (write explicitly)
Shallow object❌
One-off type❌

Best Practices

✅ Do This:

// Use interface for recursive object shapes
interface TreeNode { value: number; children: TreeNode[]; } // ✅

// Terminate recursion with null, undefined, or empty array
interface ListNode<T> { value: T; next: ListNode<T> | null; } // ✅

// Defer type-alias self-reference inside a container
type Json = string | Json[] | { [k: string]: Json }; // ✅

// Keep recursive conditional types terminating
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T; // ✅

// Name intermediate types to help the checker cache
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T; // ✅

❌ Don’t Do This:

// Don't write bare self-referential type aliases
// type Loop = Loop;                                          // ⚠️

// Don't recurse without a base case
// type Infinite<T> = T extends any ? Infinite<T> : never;    // ⚠️

// Don't use recursion for fixed-depth data
// type Config = { a: { b: { c: string } } };                 // ✅ explicit

// Don't recurse over large unions
// Distributive conditional types expand combinatorially       // ⚠️

// Don't assume deep recursion is free
// Each level costs checker time                              // ⚠️

Common Pitfalls

PitfallProblemSolution
Bare self-reference in aliasCircular reference errorDefer inside object/array
No base caseDepth limit errorTerminate with null or empty
Deep recursion on large typesSlow compilationReduce depth or use interfaces
Distributive over unionExponential expansionWrap in [T] extends [U]
Recursive type in editorUnreadable expansionUse interface to keep name
Mutually recursive aliasesOne bare reference errorsEnsure both deferred
Assuming recursion terminatesInfinite typeAdd explicit base case

Real-World Examples

1. File tree

interface FileNode {
  name: string;
  type: "file" | "directory";
  children?: FileNode[];
}

2. Linked list

interface ListNode<T> {
  value: T;
  next: ListNode<T> | null;
}

3. JSON value

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

4. AST

interface BinaryExpr {
  kind: "binary";
  op: string;
  left: Expr;
  right: Expr;
}
type Expr = BinaryExpr | { kind: "literal"; value: number };

5. Deep partial

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

6. Deep readonly

type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

7. Awaited

type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;

8. Tuple reverse

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

9. Mutual recursion

interface Person { name: string; pets: Pet[]; }
interface Pet { name: string; owner: Person; }

10. Menu structure

interface MenuItem {
  label: string;
  children?: MenuItem[];
}

Visual: Recursive Tree

┌──────────────────────────────────────────────┐
│                                              │
│              root                            │
│             /    \                           │
│           a        b                         │
│                  /   \                       │
│                b1     b2                     │
│                                              │
│  interface Tree<T> {                         │
│    value: T;                                 │
│    children: Tree<T>[];                      │
│  }                                           │
│                                              │
│  Each node has the same shape.               │
│  The recursion terminates at children: [].   │
│                                              │
└──────────────────────────────────────────────┘

Visual: Linked List Termination

┌──────────────────────────────────────────────┐
│                                              │
│  { value: 1, next: ──► { value: 2, next: ──► │
│                          { value: 3,         │
│                            next: null } } }  │
│                                              │
│  ListNode<T>                                 │
│    value: T                                  │
│    next: ListNode<T> | null                  │
│                         ▲                    │
│                         └── the terminator   │
│                                              │
│  Without | null, the chain would be infinite.│
│                                              │
└──────────────────────────────────────────────┘

Visual: Interface vs Type Alias Recursion

┌──────────────────────────────────────────────┐
│  INTERFACE (lazy resolution)                 │
│                                              │
│  interface Node {                            │
│    next: Node | null;                        │
│  }                                           │
│                                              │
│  ✅ Always allowed                           │
│  Compiler records the name and resolves      │
│  members on demand.                          │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  TYPE ALIAS (must defer)                     │
│                                              │
│  type Node = {                               │
│    next: Node | null;  ← deferred inside {}  │
│  };                                          │
│                                              │
│  ✅ Allowed because reference is inside      │
│     an object type.                          │
│                                              │
│  type Loop = Loop;                           │
│  ❌ Bare reference — circular error          │
│                                              │
└──────────────────────────────────────────────┘

Visual: Recursive Conditional Type

┌──────────────────────────────────────────────┐
│  DeepPartial<T>                              │
│                                              │
│  T extends object ?                          │
│    { [K in keyof T]?: DeepPartial<T[K]> }    │
│    : T                                       │
│                                              │
│  Config                                      │
│    └── server                                │
│          ├── host: string  → string          │
│          ├── port: number  → number          │
│          └── ssl                             │
│                ├── enabled: boolean          │
│                └── cert: string              │
│                                              │
│  Each level recurses until T is not an       │
│  object. The base case returns T unchanged.  │
│                                              │
└──────────────────────────────────────────────┘

Visual: Depth Limit

┌──────────────────────────────────────────────┐
│  Unwrap<Promise<Promise<Promise<string>>>>   │
│                                              │
│  Step 1: Promise<Promise<string>>            │
│  Step 2: Promise<string>                     │
│  Step 3: string                              │
│  Step 4: not a Promise → return string       │
│                                              │
│  ✅ Terminates in 4 steps                    │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  type Infinite<T> = T extends any            │
│    ? Infinite<T>                             │
│    : never;                                  │
│                                              │
│  Step 1: Infinite<T>                         │
│  Step 2: Infinite<T>                         │
│  Step 3: Infinite<T>                         │
│  ...                                         │
│                                              │
│  ❌ "Type instantiation is excessively deep  │
│     and possibly infinite"                   │
│                                              │
└──────────────────────────────────────────────┘

Summary

ConceptRule
Interface recursionAlways allowed, lazy resolution
Type alias recursionMust be deferred inside a container
Bare alias self-reference❌ circular reference error
Base casenull, undefined, empty array, or primitive
Mutual recursionBoth types must be interfaces or deferred aliases
Recursive conditional typeMust terminate; depth-limited
Depth limit error“Type instantiation is excessively deep”
Best forTrees, lists, JSON, ASTs
Avoid forFixed-depth data, large unions

Key takeaways:

  • Recursive types model unbounded data — trees, lists, JSON, and ASTs are the canonical examples
  • Every recursive type needs a base case — null, undefined, an empty array, or a non-recursive branch
  • Interfaces resolve lazily and always allow self-reference; type aliases must defer the reference inside an object, array, function, union, or conditional
  • Mutual recursion works when both types are interfaces or both defer their references
  • Recursive conditional types are type-level loops — DeepPartial, DeepReadonly, Awaited, and tuple utilities are built this way
  • Non-terminating recursion is caught by the depth limit, not by the runtime
  • Recursion has a performance cost — deep recursion on large types slows the checker, and fixed-depth data is better written explicitly
  • When in doubt, use interface — it is the most permissive and readable form for structural recursion

Remember: Recursive types are how TypeScript models data that contains itself. The rule is simple: interface recursion is free, type alias recursion needs a deferring construct, and recursive conditional types must make progress toward a base case. Everything else — trees, lists, JSON, DeepPartial, Awaited — follows from those three rules.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!