---
title: 'Recursive Types'
source: 'https://academia.sh/en/courses/typescript/recursive-types'
course: TypeScript
language: en
updated: '2026-08-17T18:09:53+00:00'
license: 'CC BY-SA 4.0'
---

# Recursive Types

Self-referential type definitions, deep transforms, producing key paths with type-level recursion, and the compiler's expansion depth limit.

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:

```typescript
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:

```text
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:

```text
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:

```typescript
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:

```typescript
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:

```text
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:

```typescript
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:

```text
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:

```typescript
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);
```

```text
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 `Readonly` only 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 `TS2589` diagnostic 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.
