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].valueunfolds 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
interfacefor 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
| Form | Example | Notes |
|---|---|---|
| Interface self-reference | interface N { next: N } | Always allowed |
| Type alias with deferral | type J = J[] | ... | Reference must be deferred |
| Bare type alias | type L = L | ❌ circular reference |
| Mutual recursion | A → B → A | Both must be interfaces or deferred aliases |
| Recursive conditional | T extends U ? F<T> : X | Depth-limited |
Deferring Constructs for Type Aliases
| Construct | Example |
|---|---|
| Object property | { next: T } |
| Array | T[] |
| Tuple | [T] |
| Function | () => T |
| Union member | T | string |
| Mapped type | { [K in keyof T]: F<T[K]> } |
| Conditional branch | T extends U ? F<T> : X |
Common Recursive Utility Types
| Type | Purpose |
|---|---|
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
| Error | Cause |
|---|---|
| “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
| Situation | Recursive? |
|---|---|
| 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
| Pitfall | Problem | Solution |
|---|---|---|
| Bare self-reference in alias | Circular reference error | Defer inside object/array |
| No base case | Depth limit error | Terminate with null or empty |
| Deep recursion on large types | Slow compilation | Reduce depth or use interfaces |
| Distributive over union | Exponential expansion | Wrap in [T] extends [U] |
| Recursive type in editor | Unreadable expansion | Use interface to keep name |
| Mutually recursive aliases | One bare reference errors | Ensure both deferred |
| Assuming recursion terminates | Infinite type | Add 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
| Concept | Rule |
|---|---|
| Interface recursion | Always allowed, lazy resolution |
| Type alias recursion | Must be deferred inside a container |
| Bare alias self-reference | ❌ circular reference error |
| Base case | null, undefined, empty array, or primitive |
| Mutual recursion | Both types must be interfaces or deferred aliases |
| Recursive conditional type | Must terminate; depth-limited |
| Depth limit error | “Type instantiation is excessively deep” |
| Best for | Trees, lists, JSON, ASTs |
| Avoid for | Fixed-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!