DEV Community

Cover image for TypeScript `ReadonlyDeep<T>` Without a Library: Writing the Recursive Utility Type Once and Never Again
jsmanifest
jsmanifest

Posted on Originally published at jsmanifest.com

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

TypeScript 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.

Why Built-In Readonly Isn't Enough

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

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

Solution flow showing ReadonlyDeep preventing all mutations

Key Takeaways

  • TypeScript's Readonly<T> only protects top-level properties; nested objects remain mutable and cause silent bugs.
  • A recursive ReadonlyDeep<T> utility type applies immutability to all properties at every depth without runtime overhead.
  • The implementation requires handling primitives, functions, arrays, Maps, Sets, and circular references with conditional types.
  • Type-fest's version adds complexity for edge cases; a focused implementation works for most codebases in under 30 lines.
  • Compiler performance degrades past 10 nesting levels or 100+ properties; measure before applying to massive schemas.

Understanding the ReadonlyDeep Signature

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

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.

Building ReadonlyDeep: The Base Case and Recursive Logic

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]>;
    };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

TypeScript error panel showing blocked mutations on deeply nested readonly properties

TypeScript error panel showing blocked mutations on deeply nested readonly properties

Handling Edge Cases: Functions, Maps, Sets, and Arrays

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]>;
    };
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.

ReadonlyDeep vs type-fest vs Manual Implementation

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

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
Enter fullscreen mode Exit fullscreen mode

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.

Real-World Use Cases: API Responses and Configuration Objects

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);
}
Enter fullscreen mode Exit fullscreen mode

Data flow showing readonly API response preventing mutations

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
}
Enter fullscreen mode Exit fullscreen mode

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

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 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.

Performance Considerations and Compiler Limits

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

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;
Enter fullscreen mode Exit fullscreen mode

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.

Frequently Asked Questions

How does ReadonlyDeep differ from const assertions?

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.

Can ReadonlyDeep handle circular references?

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.

Does ReadonlyDeep work with generics?

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

Will ReadonlyDeep prevent all runtime mutations?

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.

Should every type use ReadonlyDeep by default?

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.

Write It Once, Use It Forever

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.

Top comments (0)