Recursive and Self-Referential Types
Types That Reference Themselves
Think about it — real-world data is recursive everywhere. File systems are trees. JSON is nested arbitrarily deep. DOM nodes contain DOM nodes. So naturally, TypeScript supports recursive types — types that reference themselves in their definition. But there are depth limits and performance traps that will bite you if you're not careful.
Think of recursive types as Russian nesting dolls (matryoshka). Each doll contains a smaller version of itself, down to the smallest one. A recursive type is the same — TreeNode contains TreeNode[], which contains more TreeNode[], and so on. TypeScript unfolds these dolls lazily — it only checks as deep as the actual data goes, not infinitely.
Basic Recursive Types
// Binary tree
type TreeNode<T> = {
value: T;
left: TreeNode<T> | null;
right: TreeNode<T> | null;
};
const tree: TreeNode<number> = {
value: 1,
left: {
value: 2,
left: null,
right: { value: 4, left: null, right: null },
},
right: {
value: 3,
left: null,
right: null,
},
};
// Linked list
type ListNode<T> = {
value: T;
next: ListNode<T> | null;
};
// Nested comment thread
type Comment = {
id: string;
text: string;
author: string;
replies: Comment[];
};
The JSON Type
Here's a classic example you'll actually use. JSON is inherently recursive — objects and arrays can nest indefinitely:
// The complete JSON type
type JsonPrimitive = string | number | boolean | null;
type JsonArray = Json[];
type JsonObject = { [key: string]: Json };
type Json = JsonPrimitive | JsonArray | JsonObject;
// This accurately models what JSON.parse can return
function parseJson(input: string): Json {
return JSON.parse(input);
}
const data = parseJson('{"users": [{"name": "Alice", "scores": [1, 2, 3]}]}');
// data: Json — you need to narrow to access properties
TypeScript's built-in JSON.parse returns any, not a proper JSON type. This means type safety is lost at the parse boundary. Always annotate the result:
// Bad — result is any
const data = JSON.parse(text);
// Better — at least unknown forces narrowing
const data: unknown = JSON.parse(text);
// Best — validate with zod or a type guard
const data = jsonSchema.parse(JSON.parse(text));Deep Utility Types
DeepPartial
type DeepPartial<T> = T extends object
? T extends Function
? T
: T extends Array<infer E>
? Array<DeepPartial<E>>
: { [K in keyof T]?: DeepPartial<T[K]> }
: T;
interface Config {
database: {
host: string;
port: number;
pool: { min: number; max: number };
};
cache: {
ttl: number;
driver: "redis" | "memory";
};
}
type PartialConfig = DeepPartial<Config>;
// Allows: { database: { host: "localhost" } } — deeply partial
DeepReadonly
type DeepReadonly<T> = T extends object
? T extends Function
? T
: T extends Array<infer E>
? ReadonlyArray<DeepReadonly<E>>
: { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type FrozenConfig = DeepReadonly<Config>;
// All nested properties are readonly — even pool.min
DeepRequired
type DeepRequired<T> = T extends object
? T extends Function
? T
: T extends Array<infer E>
? Array<DeepRequired<E>>
: { [K in keyof T]-?: DeepRequired<T[K]> }
: T;
Why the Function check matters in recursive types
Functions are objects in JavaScript (typeof function === 'object' is false, but function instanceof Object is true). Without the Function check, recursive types would try to iterate over function properties (.name, .length, .call, .apply, etc.) and transform them.
// Without Function guard
type BadDeepReadonly<T> = T extends object
? { readonly [K in keyof T]: BadDeepReadonly<T[K]> }
: T;
// A function type gets its properties mapped:
type Bad = BadDeepReadonly<(x: string) => void>;
// Tries to make .name, .length, .bind, etc. readonly — not what you want
// With Function guard
type GoodDeepReadonly<T> = T extends object
? T extends Function ? T : { readonly [K in keyof T]: GoodDeepReadonly<T[K]> }
: T;Always short-circuit functions, Dates, RegExps, Maps, Sets, and other built-in objects you don't want to recurse into.
Recursive Conditional Types
This is where things get really expressive. TypeScript 4.1+ supports recursive conditional types with proper tail-call optimization:
// Flatten nested arrays to arbitrary depth
type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;
type A = Flatten<number[][][]>; // number
type B = Flatten<string[]>; // string
type C = Flatten<number>; // number (base case)
// Type-level string split
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: S extends ""
? []
: [S];
type D = Split<"a.b.c", ".">; // ["a", "b", "c"]
type E = Split<"hello", ".">; // ["hello"]
// Deep property access type
type DeepGet<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepGet<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never;
interface Data {
user: { profile: { name: string; age: number } };
settings: { theme: "light" | "dark" };
}
type Name = DeepGet<Data, "user.profile.name">; // string
type Theme = DeepGet<Data, "settings.theme">; // "light" | "dark"
Depth Limits and Performance
TypeScript has a recursion depth limit (around 50 levels for conditional types, configurable). Beyond that, you get the error: Type instantiation is excessively deep and possibly infinite.
// This hits the depth limit with deeply nested types
type DeepNest<T, N extends number> = N extends 0 ? T : { value: DeepNest<T, Subtract<N, 1>> };
// Workaround: use tail-recursive patterns
// TypeScript 4.5+ optimizes tail-recursive conditional types
type TailRecursive<T extends any[], Acc extends any[] = []> =
T extends [infer Head, ...infer Tail]
? TailRecursive<Tail, [...Acc, Head]>
: Acc;
Recursive types can severely impact compilation performance. Each recursion level instantiates new types. A DeepPartial on a deeply nested object with many properties can generate thousands of type instantiations. Keep recursive types shallow when possible, and add explicit depth limits for safety.
Production Scenario: Type-Safe Deep Access
// Lodash-style get with full type safety
type PathSegments<Path extends string> =
Path extends `${infer Head}.${infer Tail}`
? [Head, ...PathSegments<Tail>]
: [Path];
type DeepValue<T, Segments extends string[]> =
Segments extends [infer First extends string, ...infer Rest extends string[]]
? First extends keyof T
? Rest extends []
? T[First]
: DeepValue<T[First], Rest>
: undefined
: never;
function get<T extends object, P extends string>(
obj: T,
path: P
): DeepValue<T, PathSegments<P>> {
const segments = path.split(".") as string[];
let current: any = obj;
for (const segment of segments) {
current = current?.[segment];
}
return current;
}
const config = {
database: {
primary: { host: "db1.example.com", port: 5432 },
replica: { host: "db2.example.com", port: 5433 },
},
app: { name: "MyApp", version: "1.0.0" },
};
const host = get(config, "database.primary.host"); // string
const port = get(config, "database.primary.port"); // number
const name = get(config, "app.name"); // string
| What developers do | What they should do |
|---|---|
| Not guarding against Functions, Dates, Maps in recursive types Without guards, recursive types iterate over built-in object properties, producing unexpected results | Always short-circuit built-in objects: T extends Function ? T : ... |
| Ignoring the depth limit error TypeScript limits recursion to ~50 levels. Deeply nested types need tail-call optimization or bounded recursion. | Restructure to use tail-recursive patterns or add explicit depth limits |
| Using recursive types on very large/deep object types Each recursion level multiplies type instantiations. A 10-property object 5 levels deep creates 100,000+ instantiations. | Consider the compilation cost — recursive types on large objects can slow tsc dramatically |
| Defining JSON as 'any' or 'object' any defeats type checking. object doesn't allow property access. The recursive Json type is both safe and accurate. | Use the proper recursive JSON type: type Json = string | number | boolean | null | Json[] | { [key: string]: Json } |
Challenge: Build a Paths Type
// Build a type Paths<T> that generates all valid dot-separated
// property paths for a nested object.
//
// interface Data {
// user: {
// name: string;
// address: {
// city: string;
// zip: number;
// };
// };
// active: boolean;
// }
//
// type AllPaths = Paths<Data>;
// Should be:
// "user" | "user.name" | "user.address" | "user.address.city"
// | "user.address.zip" | "active"
// Your type here:
type Paths<T, Prefix extends string = ""> = T extends object
? T extends Function
? never
: {
[K in keyof T & string]:
| (Prefix extends "" ? K : `${Prefix}.${K}`)
| Paths<T[K], Prefix extends "" ? K : `${Prefix}.${K}`>;
}[keyof T & string]
: never;
type AllPaths = Paths<Data>;
// "user" | "active" | "user.name" | "user.address"
// | "user.address.city" | "user.address.zip"
The type recursively iterates over each key, building dot-separated paths. At each level, it emits both the current path (e.g., "user") and recursed deeper paths (e.g., "user.name"). The & string intersection filters out symbol and number keys. The Function guard prevents recursing into function properties.
- 1Recursive types reference themselves in their definition — use for trees, linked lists, JSON, and nested configurations
- 2Always guard against Function, Date, Map, Set, and other built-ins before recursing into objects
- 3TypeScript has a ~50 level recursion depth limit — use tail-recursive patterns for deep structures
- 4The proper JSON type is: string | number | boolean | null | Json[] | { [key: string]: Json }
- 5Recursive types multiply type instantiations — monitor compilation performance on large object types