TypeScript Recursive Types: Self-Referential Definitions
Recursive types are type definitions that reference themselves within their own declaration. They're essential for modeling hierarchical or self-similar data structures where nesting depth isn't...
Key Insights
- Recursive types enable TypeScript to model self-referential data structures like trees, nested menus, and JSON objects with strong type safety throughout arbitrary nesting levels
- Combining recursive types with conditional types unlocks powerful utility types like DeepReadonly and DeepPartial that transform nested object structures while preserving type information
- TypeScript limits recursion depth to prevent infinite loops, requiring tail-recursion patterns and depth control strategies for deeply nested structures
Introduction to Recursive Types
Recursive types are type definitions that reference themselves within their own declaration. They’re essential for modeling hierarchical or self-similar data structures where nesting depth isn’t known at compile time. Without recursive types, you’d need to manually define types for each nesting level or resort to unsafe any types.
The simplest recursive type is a linked list node:
type ListNode<T> = {
value: T;
next: ListNode<T> | null;
};
const head: ListNode<number> = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: null
}
}
};
Here, ListNode references itself in the next property, allowing TypeScript to understand arbitrarily long chains while maintaining type safety for the value property.
A more practical example is a comment thread structure:
interface Comment {
id: string;
text: string;
author: string;
replies: Comment[];
}
const thread: Comment = {
id: "1",
text: "Great article!",
author: "Alice",
replies: [
{
id: "2",
text: "Thanks!",
author: "Bob",
replies: [
{
id: "3",
text: "Agreed",
author: "Charlie",
replies: []
}
]
}
]
};
Basic Recursive Type Patterns
Recursive types excel at modeling tree structures and nested hierarchies. Here are the fundamental patterns you’ll use repeatedly.
For JSON-like structures, create a type that can contain itself:
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
const config: JSONValue = {
database: {
host: "localhost",
port: 5432,
credentials: {
username: "admin",
nested: {
deeply: {
value: true
}
}
}
},
features: ["auth", "logging"]
};
Binary trees require two self-references:
type BinaryTreeNode<T> = {
value: T;
left: BinaryTreeNode<T> | null;
right: BinaryTreeNode<T> | null;
};
function inorderTraversal<T>(node: BinaryTreeNode<T> | null): T[] {
if (!node) return [];
return [
...inorderTraversal(node.left),
node.value,
...inorderTraversal(node.right)
];
}
File system structures combine both arrays and object properties:
type FileSystemNode =
| { type: 'file'; name: string; size: number }
| { type: 'directory'; name: string; children: FileSystemNode[] };
const filesystem: FileSystemNode = {
type: 'directory',
name: 'src',
children: [
{ type: 'file', name: 'index.ts', size: 1024 },
{
type: 'directory',
name: 'components',
children: [
{ type: 'file', name: 'Button.tsx', size: 512 }
]
}
]
};
Recursive Conditional Types
Combining recursion with conditional types creates powerful transformation utilities. The DeepReadonly type makes all properties readonly at every nesting level:
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? T[P] extends Function
? T[P]
: DeepReadonly<T[P]>
: T[P];
};
interface MutableConfig {
server: {
host: string;
ports: {
http: number;
https: number;
};
};
}
const config: DeepReadonly<MutableConfig> = {
server: {
host: "localhost",
ports: { http: 80, https: 443 }
}
};
// Error: Cannot assign to 'http' because it is a read-only property
// config.server.ports.http = 8080;
Similarly, DeepPartial makes all nested properties optional:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
interface FullConfig {
database: {
connection: {
host: string;
port: number;
};
};
}
const partialConfig: DeepPartial<FullConfig> = {
database: {
// connection.port can be omitted
connection: { host: "localhost" }
}
};
Flattening nested arrays demonstrates recursive conditional logic:
type Flatten<T> = T extends Array<infer U>
? U extends Array<any>
? Flatten<U>
: U
: T;
type Nested = number[][][];
type Flat = Flatten<Nested>; // number
Practical Use Cases
Navigation menus are a perfect use case for recursive types:
type MenuItem = {
label: string;
path: string;
icon?: string;
children?: MenuItem[];
};
const navigation: MenuItem[] = [
{
label: "Dashboard",
path: "/dashboard"
},
{
label: "Settings",
path: "/settings",
children: [
{
label: "Profile",
path: "/settings/profile",
children: [
{ label: "Security", path: "/settings/profile/security" }
]
},
{ label: "Billing", path: "/settings/billing" }
]
}
];
Form configurations with nested field groups:
type FormField =
| { type: 'text'; name: string; label: string; validation?: string[] }
| { type: 'number'; name: string; label: string; min?: number; max?: number }
| { type: 'group'; label: string; fields: FormField[] };
const registrationForm: FormField[] = [
{ type: 'text', name: 'username', label: 'Username' },
{
type: 'group',
label: 'Address',
fields: [
{ type: 'text', name: 'street', label: 'Street' },
{ type: 'text', name: 'city', label: 'City' }
]
}
];
Type-safe query builders for GraphQL-style APIs:
type QueryBuilder<T> = {
[K in keyof T]: T[K] extends object
? QueryBuilder<T[K]> | boolean
: boolean;
};
interface User {
id: string;
profile: {
name: string;
avatar: {
url: string;
size: number;
};
};
}
const query: QueryBuilder<User> = {
id: true,
profile: {
name: true,
avatar: {
url: true,
size: false
}
}
};
Recursive Type Constraints and Limitations
TypeScript limits recursion depth to around 50 levels to prevent infinite type checking. When you hit this limit, you’ll see “Type instantiation is excessively deep and possibly infinite.”
Control depth with type parameters:
type DeepReadonlyWithDepth<T, Depth extends number = 5> =
Depth extends 0
? T
: {
readonly [P in keyof T]: T[P] extends object
? DeepReadonlyWithDepth<T[P], Prev<Depth>>
: T[P];
};
type Prev<T extends number> =
T extends 5 ? 4 :
T extends 4 ? 3 :
T extends 3 ? 2 :
T extends 2 ? 1 :
T extends 1 ? 0 :
never;
For tail recursion optimization, accumulate results in a type parameter:
type PathsToStringProps<T, Prefix extends string = ""> = {
[K in keyof T]: T[K] extends string
? `${Prefix}${K & string}`
: T[K] extends object
? PathsToStringProps<T[K], `${Prefix}${K & string}.`>
: never;
}[keyof T];
interface Config {
server: {
host: string;
port: number;
};
name: string;
}
type Paths = PathsToStringProps<Config>; // "server.host" | "name"
Advanced Patterns and Best Practices
Generate dot-notation paths for nested object access:
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}${"" extends P ? "" : "."}${P}`
: never
: never;
type Paths<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? {
[K in keyof T]-?: K extends string | number
? `${K}` | Join<K, Paths<T[K], Prev<D>>>
: never;
}[keyof T]
: "";
interface Data {
user: {
profile: {
address: {
street: string;
};
};
};
}
type ValidPaths = Paths<Data>;
// "user" | "user.profile" | "user.profile.address" | "user.profile.address.street"
function getValue<T, P extends Paths<T>>(obj: T, path: P): any {
return path.split('.').reduce((acc, part) => acc?.[part], obj as any);
}
Create generic recursive utilities:
type RecursiveMap<T, U> = {
[P in keyof T]: T[P] extends object
? RecursiveMap<T[P], U>
: U;
};
interface Schema {
name: string;
age: number;
address: {
city: string;
};
}
type Validators = RecursiveMap<Schema, (value: any) => boolean>;
// All leaf properties become validation functions
Conclusion
Recursive types are indispensable for modeling real-world hierarchical data in TypeScript. Use them for tree structures, nested configurations, and any self-referential data. Combine them with conditional types to build sophisticated utility types that transform nested structures while preserving type safety.
Keep recursion depth in mind—most practical applications stay well under TypeScript’s limits, but deeply nested structures may require depth control. When possible, prefer simpler non-recursive types for shallow hierarchies. Reserve recursive types for genuinely dynamic nesting scenarios where the depth is unknown or variable.
Master these patterns and you’ll handle complex nested data structures with confidence, catching errors at compile time rather than runtime.