{"slug": "typescript-readonlydeep-t-without-a-library-writing-the-recursive-utility-type", "title": "TypeScript `ReadonlyDeep<T>` Without a Library: Writing the Recursive Utility Type Once and Never Again", "summary": "A developer has published a self-contained TypeScript utility type, ReadonlyDeep<T>, that recursively applies readonly modifiers to every property at every nesting depth, closing the gap left by the built-in Readonly<T>, which only protects top-level properties. The implementation branches on primitives, functions, arrays (via infer and ReadonlyArray), and objects, using a mapped type that recurses into each key. The writeup notes the basic version breaks down for Map and Set, which require ReadonlyMap and ReadonlySet, and demonstrates compile-time errors on nested mutations without any runtime checks or library dependency.", "body_md": "`ReadonlyDeep<T>` Without a Library: Writing the Recursive Utility Type Once and Never Again\n*This article was written with the assistance of AI, under human supervision and review.*\n\nTypeScript'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.\n\n*Problem flow showing built-in Readonly allowing nested mutations*\n\nA recursive readonly type solves this. Every property at every depth becomes immutable. The compiler catches mutations before they ship.\n\n*Solution flow showing ReadonlyDeep preventing all mutations*\n\n`Readonly<T>` only protects top-level properties; nested objects remain mutable and cause silent bugs.\nThe 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.\n\n*Type hierarchy showing ReadonlyDeep branching by input type*\n\nThe 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.\n\nTypeScript'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.\n\nThe 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.\n\nThe 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.\n\n```\ntype Primitive =\n  | string\n  | number\n  | boolean\n  | symbol\n  | bigint\n  | undefined\n  | null;\n\ntype ReadonlyDeep<T> = T extends Primitive\n  ? T\n  : T extends Function\n  ? T\n  : T extends (infer U)[]\n  ? ReadonlyArray<ReadonlyDeep<U>>\n  : {\n      readonly [K in keyof T]: ReadonlyDeep<T[K]>;\n    };\n```\n\nThe 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`.\n\nThe 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.\n\nThis version works for most cases. A configuration object with nested arrays and objects becomes fully immutable:\n\n```\ninterface Config {\n  database: {\n    host: string;\n    port: number;\n    credentials: {\n      username: string;\n      password: string;\n    };\n  };\n  features: string[];\n}\n\ntype ImmutableConfig = ReadonlyDeep<Config>;\n\nconst config: ImmutableConfig = {\n  database: {\n    host: \"localhost\",\n    port: 5432,\n    credentials: {\n      username: \"admin\",\n      password: \"secret\",\n    },\n  },\n  features: [\"caching\", \"logging\"],\n};\n\n// All of these produce compile-time errors:\nconfig.database.port = 3306; // Error: Cannot assign to 'port'\nconfig.database.credentials.password = \"newpass\"; // Error\nconfig.features.push(\"metrics\"); // Error: Property 'push' does not exist\nconfig.features[0] = \"telemetry\"; // Error: Index signature is readonly\n```\n\nThe compiler blocks every mutation attempt. No runtime checks. No library dependency.\n\n*TypeScript error panel showing blocked mutations on deeply nested readonly properties*\n\nThe 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.\n\nThe extended version adds conditions for these collections:\n\n```\ntype Primitive =\n  | string\n  | number\n  | boolean\n  | symbol\n  | bigint\n  | undefined\n  | null;\n\ntype ReadonlyDeep<T> = T extends Primitive\n  ? T\n  : T extends Function\n  ? T\n  : T extends Map<infer K, infer V>\n  ? ReadonlyMap<K, ReadonlyDeep<V>>\n  : T extends Set<infer U>\n  ? ReadonlySet<ReadonlyDeep<U>>\n  : T extends (infer U)[]\n  ? ReadonlyArray<ReadonlyDeep<U>>\n  : {\n      readonly [K in keyof T]: ReadonlyDeep<T[K]>;\n    };\n```\n\nThe `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.\n\nFunctions with properties require careful handling. A function can have properties attached to it, and those properties should become readonly:\n\n```\ninterface Logger {\n  (message: string): void;\n  level: string;\n}\n\ntype ReadonlyLogger = ReadonlyDeep<Logger>;\n\nconst logger: ReadonlyLogger = Object.assign(\n  (message: string) => console.log(message),\n  { level: \"info\" }\n);\n\nlogger(\"test\"); // Works\nlogger.level = \"debug\"; // Error: Cannot assign to 'level'\n```\n\nThe 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.\n\nArrays 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.\n\nFor more on working with readonly collections, see [ReadonlyMap and ReadonlySet in TypeScript](https://jsmanifest.com/readonlymap-readonlyset-typescript).\n\nThe 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.\n\n*Comparison of three ReadonlyDeep approaches*\n\nThe 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.\n\nHere's the comparison on a complex type:\n\n```\ninterface APISchema {\n  users: Map<\n    string,\n    {\n      profile: {\n        name: string;\n        tags: string[];\n      };\n      settings: Set<string>;\n    }\n  >;\n  metadata: {\n    version: number;\n    handlers: Array<(data: unknown) => void>;\n  };\n}\n\n// Custom implementation handles this correctly:\ntype ImmutableSchema = ReadonlyDeep<APISchema>;\n\n// type-fest adds these guarantees:\n// - Class instances preserve private field types\n// - WeakMap and WeakSet get readonly versions\n// - Promise values become readonly\n// - Tuple types preserve length and ordering\n```\n\nFor 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.\n\nThe 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.\n\nTeams 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.\n\nFor related patterns on partial immutability, see [Partial, Required, and DeepPartial in TypeScript](https://jsmanifest.com/partial-required-deeppartial-typescript-2026).\n\nAPI 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.\n\n```\ninterface UserResponse {\n  user: {\n    id: string;\n    profile: {\n      name: string;\n      email: string;\n      preferences: {\n        theme: string;\n        notifications: string[];\n      };\n    };\n  };\n  metadata: {\n    timestamp: number;\n    version: string;\n  };\n}\n\nasync function fetchUser(\n  id: string\n): Promise<ReadonlyDeep<UserResponse>> {\n  const response = await fetch(`/api/users/${id}`);\n  return response.json();\n}\n\nasync function displayUser(id: string) {\n  const data = await fetchUser(id);\n\n  // All mutation attempts blocked:\n  data.user.profile.name = \"Modified\"; // Error\n  data.user.profile.preferences.notifications.push(\"email\"); // Error\n  data.metadata.timestamp = Date.now(); // Error\n\n  // Reading works normally:\n  console.log(data.user.profile.name);\n  console.log(data.user.profile.preferences.theme);\n}\n```\n\n*Data flow showing readonly API response preventing mutations*\n\nConfiguration 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.\n\n```\ninterface AppConfig {\n  server: {\n    port: number;\n    host: string;\n    ssl: {\n      enabled: boolean;\n      certPath: string;\n    };\n  };\n  database: {\n    connections: Array<{\n      name: string;\n      url: string;\n      poolSize: number;\n    }>;\n  };\n  features: Map<string, boolean>;\n}\n\nlet config: ReadonlyDeep<AppConfig>;\n\nfunction loadConfig(): ReadonlyDeep<AppConfig> {\n  // Load from environment or config file\n  return {\n    server: {\n      port: 3000,\n      host: \"0.0.0.0\",\n      ssl: {\n        enabled: true,\n        certPath: \"/etc/ssl/cert.pem\",\n      },\n    },\n    database: {\n      connections: [\n        {\n          name: \"primary\",\n          url: \"postgresql://localhost/db\",\n          poolSize: 10,\n        },\n      ],\n    },\n    features: new Map([\n      [\"caching\", true],\n      [\"telemetry\", false],\n    ]),\n  };\n}\n\nfunction initializeApp() {\n  config = loadConfig();\n\n  // Runtime modifications blocked:\n  config.server.port = 4000; // Error\n  config.database.connections[0].poolSize = 20; // Error\n  config.features.set(\"logging\", true); // Error\n}\n```\n\n*IDE tooltip showing readonly error when attempting to modify nested config property*\n\nForm 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.\n\nState 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.\n\nTypeScript'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.\n\n*Type checking performance showing degradation with nesting depth*\n\nA 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.\n\nThe workaround is to limit recursion depth. Add a depth parameter that decrements with each recursion:\n\n```\ntype Primitive =\n  | string\n  | number\n  | boolean\n  | symbol\n  | bigint\n  | undefined\n  | null;\n\ntype ReadonlyDeep<T, Depth extends number = 10> = Depth extends 0\n  ? Readonly<T>\n  : T extends Primitive\n  ? T\n  : T extends Function\n  ? T\n  : T extends Map<infer K, infer V>\n  ? ReadonlyMap<K, ReadonlyDeep<V, Prev<Depth>>>\n  : T extends Set<infer U>\n  ? ReadonlySet<ReadonlyDeep<U, Prev<Depth>>>\n  : T extends (infer U)[]\n  ? ReadonlyArray<ReadonlyDeep<U, Prev<Depth>>>\n  : {\n      readonly [K in keyof T]: ReadonlyDeep<T[K], Prev<Depth>>;\n    };\n\ntype Prev<N extends number> = N extends 0\n  ? 0\n  : N extends 1\n  ? 0\n  : N extends 2\n  ? 1\n  : N extends 3\n  ? 2\n  : N extends 4\n  ? 3\n  : N extends 5\n  ? 4\n  : N extends 6\n  ? 5\n  : N extends 7\n  ? 6\n  : N extends 8\n  ? 7\n  : N extends 9\n  ? 8\n  : N extends 10\n  ? 9\n  : never;\n```\n\nThis 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.\n\nMeasure compilation time with `tsc --diagnostics`. Look for the \"Type checking time\" metric. If it exceeds one second, investigate which types cause the slowness.\n\nLarge codebases should apply `ReadonlyDeep` selectively. Configuration objects, API responses, and constants benefit from it. Internal implementation types that change frequently create unnecessary compilation overhead.\n\nThe type system overhead is zero at runtime. `ReadonlyDeep` is purely a compile-time construct. The JavaScript output contains no traces of it.\n\nConst 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.\n\nNo. 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.\n\nYes. Pass generic types to ReadonlyDeep like any other type. The recursion applies to whatever type the generic resolves to at usage time.\n\nNo. 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.\n\nNo. 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.\n\nThe `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.\n\nThe 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.\n\nConfiguration 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.\n\nCompilation time matters for large schemas. Measure performance and limit recursion depth when needed. The tradeoff between safety and build speed is concrete and measurable.\n\nAdding this utility type to a codebase takes five minutes and prevents mutations that take hours to debug.", "url": "https://wpnews.pro/news/typescript-readonlydeep-t-without-a-library-writing-the-recursive-utility-type", "canonical_source": "https://dev.to/jsmanifest/typescript-readonlydeep-without-a-library-writing-the-recursive-utility-type-once-and-never-2blp", "published_at": "2026-10-27 06:04:30+00:00", "updated_at": "2026-10-27 06:12:40.099165+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["TypeScript"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/typescript-readonlydeep-t-without-a-library-writing-the-recursive-utility-type", "markdown": "https://wpnews.pro/news/typescript-readonlydeep-t-without-a-library-writing-the-recursive-utility-type.md", "text": "https://wpnews.pro/news/typescript-readonlydeep-t-without-a-library-writing-the-recursive-utility-type.txt", "jsonld": "https://wpnews.pro/news/typescript-readonlydeep-t-without-a-library-writing-the-recursive-utility-type.jsonld"}}