Lesson 18 / 23
Recursive Types
Self-referential type definitions, deep transforms, producing key paths with type-level recursion, and the compiler's expansion depth limit.
Contents
Measurement records do not always carry a flat structure: sub-records sit underneath a record, nested sections sit underneath a configuration object. The type of such structures has to refer to itself.
The Type Aliases lesson set the limit of this capability: a reference inside an object field is valid, a direct reference is not. This lesson covers how far that capability can be pushed — deep transforms and recursion at the type level — and where the compiler stops.
Self-Referential Data Types
The typical example of data whose structure is not known ahead of time and can nest is JSON itself:
type JsonValue = | string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; function depth(value: JsonValue): number { if (Array.isArray(value)) { return 1 + Math.max(0, ...value.map(depth)); } if (typeof value === "object" && value !== null) { return 1 + Math.max(0, ...Object.values(value).map(depth)); } return 0; } const record: JsonValue = { id: "s-01", value: 21.4, tags: ["boiler", "shift-3"], location: { building: "A", floor: 2 }, }; console.log(depth(record)); console.log(depth(21.4));
Output:
2 0
The type is a six-member union, and its last two members refer to itself. Because the references sit inside a field type, the compiler resolves them only when needed; no infinite expansion results.
The type also carries a guarantee. If the line
const invalid: JsonValue = { time: new Date() }; is added at the end of the file,
after a blank line:
s1.ts(29,7): error TS2322: Type '{ time: Date; }' is not assignable to type 'JsonValue'.
Types of property 'time' are incompatible.
Type 'Date' is not assignable to type 'JsonValue | undefined'.
Type 'Date' is not assignable to type '{ [key: string]: JsonValue; }'.
Index signature for type 'string' is missing in type 'Date'.
This is a test that separates serializable data from non-serializable data. A
JSON.stringify call silently turns a date object into a string, and the type changes
after parsing; the JsonValue type moves this silent conversion to build time.
The depth function is recursive too and tracks the type’s structure: the base case
and the reduction step built in the Programming Fundamentals course’s recursion lesson
correspond here to the type’s members.
Deep Transforms
Readonly from the utility types lesson only applies at the first level:
interface Configuration { name: string; thresholds: { min: number; max: number }; } const shallow: Readonly<Configuration> = { name: "boiler-2", thresholds: { min: -40, max: 85 }, }; shallow.thresholds.max = 90; console.log(shallow.thresholds.max);
This file passes the type check and prints 90. The shallow.thresholds field is
read-only, but the fields of the object it points to are not.
A recursive mapped type closes this gap:
type DeepReadonly<T> = { readonly [A in keyof T]: T[A] extends object ? DeepReadonly<T[A]> : T[A]; }; interface Configuration { name: string; thresholds: { min: number; max: number }; channels: { sensor: string; unit: string }[]; } const configuration: DeepReadonly<Configuration> = { name: "boiler-2", thresholds: { min: -40, max: 85 }, channels: [{ sensor: "temperature", unit: "C" }], }; console.log(configuration.thresholds.max, configuration.channels[0].sensor);
The output is 85 temperature. If the line
configuration.thresholds.max = 90; is added at the end of the file, after a blank
line:
s3.ts(19,26): error TS2540: Cannot assign to 'max' because it is a read-only property.
The mapping tests the T[A] extends object condition on every field; it reapplies
itself on fields that are objects and stops on primitive fields. Because arrays are
objects too, the channels field turns into a readonly array, and its elements’
fields become read-only as well.
The same warning from earlier lessons still applies: this guarantee belongs to build time. The configuration object can still be changed at run time; freezing it deeply is a separate operation.
Recursion at the Type Level
Recursion is not limited to defining a data shape; it is also used to compute at the type level. A type that produces every key path of a nested object is the typical example of this:
type Path<T> = T extends object ? { [A in keyof T & string]: T[A] extends object ? A | `${A}.${Path<T[A]>}` : A; }[keyof T & string] : never; interface Configuration { name: string; thresholds: { min: number; max: number }; network: { address: string; identity: { user: string } }; } const valid: Path<Configuration>[] = [ "name", "thresholds", "thresholds.min", "network.identity.user", ]; console.log(valid.length);
The output is 4. If the line
const wrongPath: Path<Configuration> = "network.zone"; is added at the end of the
file, after a blank line:
s6.ts(21,7): error TS2322: Type '"network.zone"' is not assignable to type '"name" | "network" | "network.address" | "network.identity" | "network.identity.user" | "thresholds" | "thresholds.max" | "thresholds.min"'.
The diagnostic writes out the computation’s full result: eight valid paths. The
structure is a composition of three lessons. The mapped type produces a value for
every field; the [keyof T & string] indexed access takes the union of those values;
the template literal type joins sub-paths with the prepended name; the conditional
type stops the recursion on fields that are not objects.
This type’s use is an interface that takes a configuration key as a string. The path string is no longer a free string but a closed set; a misspelled key is caught at build time.
The Depth Limit
The compiler applies an upper limit to prevent infinite expansion. The limit can be observed by building a tuple that counts its own length:
type Repeat<N extends number, B extends unknown[] = []> = B["length"] extends N ? B : Repeat<N, [...B, unknown]>; type Result = Repeat<1001>; declare const s: Result; console.log(s.length);
s10.ts(5,15): error TS2589: Type instantiation is excessively deep and possibly infinite.
When Repeat<999> is written in the same file, no diagnostic comes. The exact value
of the limit depends on the compiler version, and code relying on it is fragile; what
matters is that a limit exists.
Recursion that stays under the limit has a cost too: every expansion increases the compiler’s running time. Types like path generation can noticeably slow down type checking on large configuration objects. The time-and-space trade-off from the Algorithms course shows up here as a trade-off between build time and type precision.
For cases that need unbounded depth, a type-level solution is not suitable; the right tool is run-time validation and a branded type.
Writing Recursion
Three rules keep recursive types healthy.
The base case is written explicitly. Every example above has a condition where the
recursion stops: the primitive members for JsonValue, the extends object test for
DeepReadonly, the same test for Path. A type with no base case expands to the
compiler’s limit and gives TS2589.
Depth must not come from data. Expansion depth should depend on the type’s
structure, not on a number given by the caller. The Repeat<N> example deliberately
breaks this rule to show the limit.
The result is tested. A type computed at the type level can be tested by declaring
an array holding the expected values — the valid declaration above does exactly
this. An incorrectly computed type produces surprising diagnostics everywhere it is
used.
Summary
- A self-reference inside an object field is valid; the compiler resolves these references only when needed.
- Utility types like
Readonlyonly apply at the first level; a recursive mapped type is written for a deep transform. - A mapped type, indexed access, a template literal type, and a conditional type together can produce nested key paths as a closed union.
- The compiler gives the
TS2589diagnostic past a certain expansion depth; the limit’s value depends on the version, and code relying on it is fragile. - In a recursive type, the base case is written explicitly, depth comes from structure rather than data, and the result is tested with a separate declaration.
Next Step
Every example up to this point ran as a single file with compiler options given by hand. In a real project, options are collected in a configuration file and the strictness level is chosen deliberately. The next topic covers project configuration: which flag catches which error class, the role of declaration files, working together with JavaScript, and separating type checking from publish output.
To keep your progress and take notes, Log in
My notes
Log in to take notes.