Recursive Types
Recursive types are types that reference themselves in their own definition. They are essential for modeling hierarchical or nested data structures like trees, file systems, nested menus, and JSON values — anywhere data can contain more data of the same shape.
TypeScript has supported recursive type aliases since version 3.7, and the compiler handles them efficiently for most common patterns.
Why Recursive Types?
Many real-world data structures are inherently recursive. A folder can contain files or more folders. A comment can have replies that are also comments. A JSON value can be a number, string, boolean, array of JSON values, or an object whose values are JSON values.
Without recursive types you would have to pick an arbitrary nesting depth and duplicate your type definitions, which is both fragile and inaccurate.
The JSON Type
The canonical example of a recursive type is a complete definition of JSON-compatible values:
type JSONPrimitive = string | number | boolean | null;
type JSONValue =
| JSONPrimitive
| JSONValue[]
| { [key: string]: JSONValue };
// All of these are valid JSONValue:
const a: JSONValue = 42;
const b: JSONValue = "hello";
const c: JSONValue = [1, 2, 3];
const d: JSONValue = { name: "Alice", scores: [10, 20] };
const e: JSONValue = { nested: { deep: { value: true } } };JSONValue references itself inside its own definition. TypeScript resolves this lazily, so there is no infinite loop at compile time.Tree Nodes
A binary tree is another classic recursive structure. Each node holds a value and optionally has left and right child nodes of the same type:
interface TreeNode<T> {
value: T;
left?: TreeNode<T>;
right?: TreeNode<T>;
}
const tree: TreeNode<number> = {
value: 10,
left: {
value: 5,
left: { value: 2 },
right: { value: 7 },
},
right: {
value: 15,
right: { value: 20 },
},
};
function sum(node: TreeNode<number> | undefined): number {
if (!node) return 0;
return node.value + sum(node.left) + sum(node.right);
}
console.log(sum(tree)); // 59TreeNode<T> are extremely reusable. The same interface works for trees of strings, objects, or any other type.Nested Menu Structures
Navigation menus often have unlimited nesting depth. A recursive type models this perfectly:
interface MenuItem {
label: string;
href?: string;
children?: MenuItem[];
}
const nav: MenuItem[] = [
{
label: "Products",
children: [
{ label: "Web", href: "/web" },
{
label: "Mobile",
children: [
{ label: "iOS", href: "/ios" },
{ label: "Android", href: "/android" },
],
},
],
},
{ label: "About", href: "/about" },
];
function flattenMenu(items: MenuItem[]): string[] {
return items.flatMap((item) => [
item.label,
...(item.children ? flattenMenu(item.children) : []),
]);
}
console.log(flattenMenu(nav));
// ["Products", "Web", "Mobile", "iOS", "Android", "About"]File System Representation
A file system has directories that can contain files or other directories. Union types combined with recursion model this precisely:
interface File {
kind: "file";
name: string;
size: number;
}
interface Directory {
kind: "directory";
name: string;
children: (File | Directory)[];
}
type FSEntry = File | Directory;
const root: Directory = {
kind: "directory",
name: "/",
children: [
{ kind: "file", name: "README.md", size: 1024 },
{
kind: "directory",
name: "src",
children: [
{ kind: "file", name: "index.ts", size: 512 },
{ kind: "file", name: "utils.ts", size: 768 },
],
},
],
};
function totalSize(entry: FSEntry): number {
if (entry.kind === "file") return entry.size;
return entry.children.reduce((acc, child) => acc + totalSize(child), 0);
}
console.log(totalSize(root)); // 2304Deeply Nested Objects
Sometimes you need a type for objects of unknown nesting depth where every leaf is the same type. The DeepNested pattern handles this:
type DeepNested<T> = T | { [key: string]: DeepNested<T> };
const config: DeepNested<string> = {
database: {
host: "localhost",
credentials: {
user: "admin",
password: "secret",
},
},
app: {
name: "MyApp",
},
};
// Flatten nested config into dot-notation keys
function flatten(
obj: DeepNested<string>,
prefix = ""
): Record<string, string> {
if (typeof obj === "string") return { [prefix]: obj };
return Object.entries(obj).reduce((acc, [key, val]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return { ...acc, ...flatten(val, fullKey) };
}, {} as Record<string, string>);
}
console.log(flatten(config));
// { "database.host": "localhost", "database.credentials.user": "admin", ... }Recursive Conditional Types
Recursive conditional types let you traverse type structures at the type level. A common use case is deeply unwrapping a Promise chain:
type DeepAwaited<T> =
T extends Promise<infer U>
? DeepAwaited<U>
: T;
type A = DeepAwaited<Promise<Promise<Promise<string>>>>;
// string
type B = DeepAwaited<Promise<number>>;
// number
type C = DeepAwaited<boolean>;
// booleanDeep Readonly
A recursive mapped type that makes every property at every level of nesting read-only:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
interface Config {
server: {
host: string;
port: number;
tls: {
enabled: boolean;
cert: string;
};
};
debug: boolean;
}
type FrozenConfig = DeepReadonly<Config>;
declare const cfg: FrozenConfig;
// cfg.server.host = "x"; // Error: read-only property
// cfg.server.tls.enabled = true; // Error: deeply read-onlyDeep Partial
The built-in Partial<T> only makes the top level optional. A recursive version makes every nested property optional too, which is useful for deep merge utilities:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface AppSettings {
theme: {
colors: {
primary: string;
secondary: string;
};
fontSize: number;
};
language: string;
}
type SettingsPatch = DeepPartial<AppSettings>;
function mergeSettings(
base: AppSettings,
patch: SettingsPatch
): AppSettings {
return {
...base,
...patch,
theme: {
...base.theme,
...(patch.theme ?? {}),
colors: {
...base.theme.colors,
...(patch.theme?.colors ?? {}),
},
},
};
}Mutual Recursion
Two types can reference each other — this is called mutual recursion. TypeScript handles this as long as both types are declared before use:
interface Literal {
kind: "literal";
value: number;
}
interface BinaryOp {
kind: "binary";
op: "+" | "-" | "*" | "/";
left: Expression;
right: Expression;
}
type Expression = Literal | BinaryOp;
function evaluate(expr: Expression): number {
if (expr.kind === "literal") return expr.value;
const l = evaluate(expr.left);
const r = evaluate(expr.right);
switch (expr.op) {
case "+": return l + r;
case "-": return l - r;
case "*": return l * r;
case "/": return l / r;
}
}
const expr: Expression = {
kind: "binary",
op: "+",
left: { kind: "literal", value: 3 },
right: {
kind: "binary",
op: "*",
left: { kind: "literal", value: 4 },
right: { kind: "literal", value: 5 },
},
};
console.log(evaluate(expr)); // 23Linked List
interface ListNode<T> {
value: T;
next: ListNode<T> | null;
}
function fromArray<T>(arr: T[]): ListNode<T> | null {
if (arr.length === 0) return null;
const [head, ...tail] = arr;
return { value: head, next: fromArray(tail) };
}
function toArray<T>(node: ListNode<T> | null): T[] {
const result: T[] = [];
let current = node;
while (current !== null) {
result.push(current.value);
current = current.next;
}
return result;
}
const list = fromArray([1, 2, 3, 4, 5]);
console.log(toArray(list)); // [1, 2, 3, 4, 5]Performance Considerations
Prefer interface over type alias for recursive object shapes — interfaces are lazily evaluated.
Avoid recursive types in hot paths computed thousands of times across a large codebase.
TypeScript imposes a depth limit on recursive conditional types (around 100 levels).
Use tsc --diagnostics to measure type-checking time if you suspect a performance problem.
When a recursive type only needs a few levels, consider expanding it manually instead.
type alias to an interface, or add one layer of indirection (wrap in an object or array).Quick Reference
Pattern | Use Case | Notes |
|---|---|---|
interface T { children?: T[] } | Trees, menus, file system | Most common pattern |
type T = Primitive | T[] | Arrays of arrays (JSON arrays) | Use type alias |
type T = V | { [k: string]: T } | Config objects, JSON objects | Index signature recursion |
type D<T> = T extends P<infer U> ? D<U> : T | Unwrap nested generics | Requires TS 4.1+ for tail-rec |
type DR<T> = { readonly [K in keyof T]: DR<T[K]> } | Deep Readonly / Deep Partial | Mapped + recursive |
JSONValue pattern, then move to generic tree nodes, and finally explore recursive conditional types when you need type-level traversal.