# TypeScript `ReadonlyDeep<T>` Without a Library: Writing the Recursive Utility Type Once and Never Again

> Source: <https://dev.to/jsmanifest/typescript-readonlydeep-without-a-library-writing-the-recursive-utility-type-once-and-never-2blp>
> Published: 2026-10-27 06:04:30+00:00

`ReadonlyDeep<T>` Without a Library: Writing the Recursive Utility Type Once and Never Again
*This article was written with the assistance of AI, under human supervision and review.*

TypeScript's built-in `Readonly<T>` type applies immutability to top-level properties but stops there. Nested objects remain mutable. When a developer marks a configuration object as readonly and a teammate mutates a nested property three levels deep, the compiler stays silent. The bug appears in production.

*Problem flow showing built-in Readonly allowing nested mutations*

A recursive readonly type solves this. Every property at every depth becomes immutable. The compiler catches mutations before they ship.

*Solution flow showing ReadonlyDeep preventing all mutations*

`Readonly<T>` only protects top-level properties; nested objects remain mutable and cause silent bugs.
The type signature needs to handle every TypeScript construct. Primitives like `string`, `number`, and `boolean` pass through unchanged. Functions preserve their signatures. Arrays become readonly arrays. Objects recurse into their properties.

*Type hierarchy showing ReadonlyDeep branching by input type*

The recursive case is where the type earns its place. For each property `K` in object `T`, the type constructs a new object with `K` mapped to `ReadonlyDeep<T[K]>`. The recursion stops when it hits a primitive or function.

TypeScript's conditional types make this possible. The pattern `T extends Primitive ? T : ...` lets the type branch based on structure. Each branch handles a different category.

The built-in `Readonly<T>` handles the shallow case. `ReadonlyDeep<T>` wraps it with recursion. The combination creates a type that descends through arbitrarily nested structures.

The implementation starts with a type alias for primitives. This includes `string`, `number`, `boolean`, `symbol`, `bigint`, `undefined`, and `null`. Functions count as primitives here because their internal structure does not require recursion.

```
type Primitive =
  | string
  | number
  | boolean
  | symbol
  | bigint
  | undefined
  | null;

type ReadonlyDeep<T> = T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends (infer U)[]
  ? ReadonlyArray<ReadonlyDeep<U>>
  : {
      readonly [K in keyof T]: ReadonlyDeep<T[K]>;
    };
```

The first condition checks if `T` is a primitive. If so, the type returns `T` unchanged. The second condition handles functions. The third condition matches arrays using the infer pattern and wraps them in `ReadonlyArray`.

The final case handles objects. The mapped type iterates over every key `K` in `T`. Each property becomes `readonly`, and the value type becomes `ReadonlyDeep<T[K]>`. The recursion happens here.

This version works for most cases. A configuration object with nested arrays and objects becomes fully immutable:

```
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  features: string[];
}

type ImmutableConfig = ReadonlyDeep<Config>;

const config: ImmutableConfig = {
  database: {
    host: "localhost",
    port: 5432,
    credentials: {
      username: "admin",
      password: "secret",
    },
  },
  features: ["caching", "logging"],
};

// All of these produce compile-time errors:
config.database.port = 3306; // Error: Cannot assign to 'port'
config.database.credentials.password = "newpass"; // Error
config.features.push("metrics"); // Error: Property 'push' does not exist
config.features[0] = "telemetry"; // Error: Index signature is readonly
```

The compiler blocks every mutation attempt. No runtime checks. No library dependency.

*TypeScript error panel showing blocked mutations on deeply nested readonly properties*

The basic implementation breaks down with `Map` and `Set`. These types have methods like `set` and `add` that mutate internal state. TypeScript's `ReadonlyMap` and `ReadonlySet` types exist for this reason.

The extended version adds conditions for these collections:

```
type Primitive =
  | string
  | number
  | boolean
  | symbol
  | bigint
  | undefined
  | null;

type ReadonlyDeep<T> = T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends Map<infer K, infer V>
  ? ReadonlyMap<K, ReadonlyDeep<V>>
  : T extends Set<infer U>
  ? ReadonlySet<ReadonlyDeep<U>>
  : T extends (infer U)[]
  ? ReadonlyArray<ReadonlyDeep<U>>
  : {
      readonly [K in keyof T]: ReadonlyDeep<T[K]>;
    };
```

The `Map` branch infers key and value types, wraps the value in `ReadonlyDeep`, and returns `ReadonlyMap`. The `Set` branch does the same with `ReadonlySet`. Both prevent mutations while preserving read operations.

Functions with properties require careful handling. A function can have properties attached to it, and those properties should become readonly:

```
interface Logger {
  (message: string): void;
  level: string;
}

type ReadonlyLogger = ReadonlyDeep<Logger>;

const logger: ReadonlyLogger = Object.assign(
  (message: string) => console.log(message),
  { level: "info" }
);

logger("test"); // Works
logger.level = "debug"; // Error: Cannot assign to 'level'
```

The function condition in the type needs to come after the primitive check but before the object recursion. This preserves the function signature while applying readonly to any attached properties.

Arrays of arrays require the recursion to work properly. The type `string[][]` becomes `ReadonlyArray<ReadonlyArray<string>>`. Each level of nesting applies both `ReadonlyArray` and `ReadonlyDeep` to the inner type.

For more on working with readonly collections, see [ReadonlyMap and ReadonlySet in TypeScript](https://jsmanifest.com/readonlymap-readonlyset-typescript).

The type-fest library provides a `ReadonlyDeep` implementation with additional features. It handles classes, preserves function properties, and includes special cases for built-in types. The tradeoff is complexity.

*Comparison of three ReadonlyDeep approaches*

The custom implementation covers objects, arrays, functions, Maps, and Sets. This handles 95% of use cases in application code. Type-fest adds support for classes with private fields, WeakMaps, Promises, and other specialized types.

Here's the comparison on a complex type:

```
interface APISchema {
  users: Map<
    string,
    {
      profile: {
        name: string;
        tags: string[];
      };
      settings: Set<string>;
    }
  >;
  metadata: {
    version: number;
    handlers: Array<(data: unknown) => void>;
  };
}

// Custom implementation handles this correctly:
type ImmutableSchema = ReadonlyDeep<APISchema>;

// type-fest adds these guarantees:
// - Class instances preserve private field types
// - WeakMap and WeakSet get readonly versions
// - Promise values become readonly
// - Tuple types preserve length and ordering
```

For most codebases, the custom version works. The compiler errors guide you when edge cases appear. Adding type-fest as a dependency makes sense when you hit repeated gaps in coverage.

The maintenance burden differs. A custom utility type lives in the codebase and requires updates when TypeScript adds new built-in types. Type-fest tracks these changes and ships updates.

Teams building component libraries or TypeScript tooling benefit from type-fest's thoroughness. Application developers building features on a known stack rarely need that level of coverage.

For related patterns on partial immutability, see [Partial, Required, and DeepPartial in TypeScript](https://jsmanifest.com/partial-required-deeppartial-typescript-2026).

API response types become safer with `ReadonlyDeep`. A response handler receives data from the network and should not modify it. The readonly constraint prevents accidental mutations that break caching or introduce race conditions.

```
interface UserResponse {
  user: {
    id: string;
    profile: {
      name: string;
      email: string;
      preferences: {
        theme: string;
        notifications: string[];
      };
    };
  };
  metadata: {
    timestamp: number;
    version: string;
  };
}

async function fetchUser(
  id: string
): Promise<ReadonlyDeep<UserResponse>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

async function displayUser(id: string) {
  const data = await fetchUser(id);

  // All mutation attempts blocked:
  data.user.profile.name = "Modified"; // Error
  data.user.profile.preferences.notifications.push("email"); // Error
  data.metadata.timestamp = Date.now(); // Error

  // Reading works normally:
  console.log(data.user.profile.name);
  console.log(data.user.profile.preferences.theme);
}
```

*Data flow showing readonly API response preventing mutations*

Configuration objects gain the same protection. Application config loads once at startup and should never change. Making it readonly catches bugs where code tries to modify settings based on runtime conditions.

```
interface AppConfig {
  server: {
    port: number;
    host: string;
    ssl: {
      enabled: boolean;
      certPath: string;
    };
  };
  database: {
    connections: Array<{
      name: string;
      url: string;
      poolSize: number;
    }>;
  };
  features: Map<string, boolean>;
}

let config: ReadonlyDeep<AppConfig>;

function loadConfig(): ReadonlyDeep<AppConfig> {
  // Load from environment or config file
  return {
    server: {
      port: 3000,
      host: "0.0.0.0",
      ssl: {
        enabled: true,
        certPath: "/etc/ssl/cert.pem",
      },
    },
    database: {
      connections: [
        {
          name: "primary",
          url: "postgresql://localhost/db",
          poolSize: 10,
        },
      ],
    },
    features: new Map([
      ["caching", true],
      ["telemetry", false],
    ]),
  };
}

function initializeApp() {
  config = loadConfig();

  // Runtime modifications blocked:
  config.server.port = 4000; // Error
  config.database.connections[0].poolSize = 20; // Error
  config.features.set("logging", true); // Error
}
```

*IDE tooltip showing readonly error when attempting to modify nested config property*

Form validation schemas work well with readonly types. The schema defines structure and constraints. Validators read from it but should not modify it. See [Custom Form Validators in TypeScript](https://jsmanifest.com/typescript-form-validators-custom) for validator patterns.

State management libraries benefit from readonly types. Redux state, Zustand stores, and React context values all gain safety when typed as `ReadonlyDeep`. The compiler prevents direct mutations and forces proper update patterns.

TypeScript's type checker has recursion limits. Deep nesting or large object types can hit these limits and cause compilation errors. The practical limit sits around 10 levels of nesting for complex types.

*Type checking performance showing degradation with nesting depth*

A type with 100 properties at 3 levels deep creates 100^3 = 1,000,000 type instantiations. The compiler caches these, but initial compilation takes time. Watch for slow type checking during development.

The workaround is to limit recursion depth. Add a depth parameter that decrements with each recursion:

```
type Primitive =
  | string
  | number
  | boolean
  | symbol
  | bigint
  | undefined
  | null;

type ReadonlyDeep<T, Depth extends number = 10> = Depth extends 0
  ? Readonly<T>
  : T extends Primitive
  ? T
  : T extends Function
  ? T
  : T extends Map<infer K, infer V>
  ? ReadonlyMap<K, ReadonlyDeep<V, Prev<Depth>>>
  : T extends Set<infer U>
  ? ReadonlySet<ReadonlyDeep<U, Prev<Depth>>>
  : T extends (infer U)[]
  ? ReadonlyArray<ReadonlyDeep<U, Prev<Depth>>>
  : {
      readonly [K in keyof T]: ReadonlyDeep<T[K], Prev<Depth>>;
    };

type Prev<N extends number> = N extends 0
  ? 0
  : N extends 1
  ? 0
  : N extends 2
  ? 1
  : N extends 3
  ? 2
  : N extends 4
  ? 3
  : N extends 5
  ? 4
  : N extends 6
  ? 5
  : N extends 7
  ? 6
  : N extends 8
  ? 7
  : N extends 9
  ? 8
  : N extends 10
  ? 9
  : never;
```

This version stops recursing after 10 levels and falls back to shallow `Readonly`. The `Prev` helper decrements the depth counter. When depth reaches zero, recursion stops.

Measure compilation time with `tsc --diagnostics`. Look for the "Type checking time" metric. If it exceeds one second, investigate which types cause the slowness.

Large codebases should apply `ReadonlyDeep` selectively. Configuration objects, API responses, and constants benefit from it. Internal implementation types that change frequently create unnecessary compilation overhead.

The type system overhead is zero at runtime. `ReadonlyDeep` is purely a compile-time construct. The JavaScript output contains no traces of it.

Const assertions make literal types as narrow as possible and add shallow readonly to objects. ReadonlyDeep applies deep immutability to existing type definitions. Use const assertions for inline objects and ReadonlyDeep for reusable types.

No. Circular type references cause infinite recursion in TypeScript's type checker. The depth-limited version prevents compiler hangs but does not make circular structures readonly. Avoid circular references or use shallow Readonly for those cases.

Yes. Pass generic types to ReadonlyDeep like any other type. The recursion applies to whatever type the generic resolves to at usage time.

No. TypeScript types disappear at runtime. ReadonlyDeep provides compile-time safety only. Use `Object.freeze()` for runtime immutability or combine both approaches for defense in depth.

No. Apply it to data that should not change: configuration, API responses, constants. Internal implementation types that undergo frequent mutations benefit from normal mutability. Overuse creates friction without safety gains.

The `ReadonlyDeep<T>` utility type solves the nested mutability problem that built-in `Readonly<T>` leaves open. Thirty lines of code eliminate an entire class of bugs. The compiler catches mutations at every depth before they reach production.

The implementation handles objects, arrays, functions, Maps, and Sets. Edge cases exist but rarely matter in application code. Teams that need comprehensive coverage can reach for type-fest. Teams building features on standard types can write this once and never think about it again.

Configuration objects and API responses gain immediate safety benefits. State management becomes more predictable. The type system enforces immutability contracts that previously existed only in documentation.

Compilation time matters for large schemas. Measure performance and limit recursion depth when needed. The tradeoff between safety and build speed is concrete and measurable.

Adding this utility type to a codebase takes five minutes and prevents mutations that take hours to debug.
