Recursive types
Describe self-nesting data such as JSON or trees by referring to the type alias inside its own definition.
type ValueOrArray<T> = T | Array<ValueOrArray<T>>;- Added in TypeScript 3.7
Parameters
| Parameter | Type | Description |
|---|---|---|
$Toptional | type parameter | Optional. The element type carried through each level of nesting; a recursive alias does not have to be generic. |
Return value
A type whose definition mentions itself, matching data nested to any depth.
Errors
tsc reports "Type instantiation is excessively deep and possibly infinite" when a recursive type expands past its internal depth limit (roughly 50 levels, or 1000 iterations for tail-recursive conditional types, which TypeScript 4.5 made far more generous via tail-recursion elimination).
Changelog
- PHP 3.7Type aliases may reference themselves at the top level. Before this, `type ValueOrArray<T> = T | Array<ValueOrArray<T>>` failed with "Type alias 'ValueOrArray' circularly references itself" and needed a helper interface: `interface ArrayOfValueOrArray<T> extends Array<ValueOrArray<T>> {}`.
- PHP 3.7Recursive references inside tuple types became valid, enabling shapes such as `type VirtualNode = string | [string, { [key: string]: any }, ...VirtualNode[]]`.
- PHP 4.1Recursive conditional types were allowed, so `type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T` and similar deep transformations compile.
A recursive type names itself inside its own definition. That is the only way to describe data whose depth is not known in advance: a JSON document, a file tree, a comment thread, a virtual DOM node. Interfaces could always do this; type aliases could not until TypeScript 3.7 lifted the restriction at the top level of an alias.
When to reach for it
- Modelling arbitrary JSON: a union of the primitives plus
{ [key: string]: Json }andJson[]. - Any node-with-children shape, usually generic:
type TreeNode<T> = { value: T; children: TreeNode<T>[] }. - Deep transformations built from recursive mapped or conditional types, such as
DeepReadonlyorDeepPartial.
What trips people up
The recursion must sit behind something that defers evaluation — a union member, an object property, an array or tuple element. A bare type Foo = Foo is still rejected, and so is a recursion that the compiler must expand eagerly to know the type's shape.
The other wall is depth. Recursive conditional and mapped types expand at check time, and a type that recurses too far produces "Type instantiation is excessively deep and possibly infinite". When that happens, cap the depth with an explicit counter type, or fall back to an interface, which is resolved lazily and never hits the limit.
Narrowing recursive unions is ordinary work: Array.isArray first, then typeof value === "object" && value !== null, then the primitives.
Example
When to use it
- Typing a config or API document whose nesting depth is not fixed.
- Walking a file tree or comment thread with a recursive function whose parameter type matches the data.
- Building a DeepReadonly or DeepPartial helper from a mapped type that recurses into each property.
Frequently asked questions
How do I write a type for arbitrary JSON in TypeScript?
Use a recursive union: `type Json = string | number | boolean | null | { [key: string]: Json } | Json[]`. This has compiled directly since TypeScript 3.7; earlier versions needed helper interfaces for the object and array members.
Why do I get "Type alias circularly references itself"?
The recursion is in a position the compiler must resolve eagerly. Put the self-reference behind a union member, an object property, or an array or tuple element — `type Foo = Foo` and similar direct aliases are still rejected in every version.
What causes "Type instantiation is excessively deep and possibly infinite"?
A recursive conditional or mapped type expanded past the compiler's depth limit, around 50 levels. Add an explicit depth-counter type parameter that stops the recursion, or use an interface, which resolves lazily and does not hit the limit.
More examples
A generic tree and a recursive traversal
Prints `src`, `src/index.ts`, `src/lib`, `src/lib/parse.ts` on four lines. The alias refers to itself through the `children` array, which is enough deferral for the compiler, and the traversal function's own recursion mirrors the type exactly — every level type-checks with no assertions.
type TreeNode<T> = { value: T; children: TreeNode<T>[] };
const tree: TreeNode<string> = {
value: "src",
children: [
{ value: "index.ts", children: [] },
{ value: "lib", children: [{ value: "parse.ts", children: [] }] },
],
};
function paths<T>(node: TreeNode<T>, prefix = ""): string[] {
const here = prefix ? `${prefix}/${node.value}` : String(node.value);
return [here, ...node.children.flatMap((c) => paths(c, here))];
}
console.log(paths(tree).join("\n"));A recursive mapped type: DeepReadonly
Prints `dark 2`. The mapped type recurses into `theme` and, because homomorphic mapped types preserve arrays, turns `fonts` into a `readonly string[]` — so `push` is gone from the type. The commented lines are compile errors only: `frozen` and `settings` are the same object, and mutating it through `settings` still works.
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
const settings = {
theme: { mode: "dark", accent: "#0af" },
fonts: ["Inter", "Mono"],
};
const frozen: DeepReadonly<typeof settings> = settings;
// tsc rejects both of these. Nothing is frozen at runtime.
// frozen.theme.mode = "light";
// frozen.fonts.push("Serif");
console.log(frozen.theme.mode, frozen.fonts.length);
Discussion