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

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

TypeScript ReadonlyDeep 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 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 Key Takeaways TypeScript's Readonly only protects top-level properties; nested objects remain mutable and cause silent bugs. A recursive ReadonlyDeep 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 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. 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 handles the shallow case. ReadonlyDeep 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 extends Primitive ? T : T extends Function ? T : T extends (infer U)[] ? ReadonlyArray> : { readonly [K in keyof T]: ReadonlyDeep; }; 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. 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; 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 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 extends Primitive ? T : T extends Function ? T : T extends Map ? ReadonlyMap> : T extends Set ? ReadonlySet> : T extends (infer U)[] ? ReadonlyArray> : { readonly [K in keyof T]: ReadonlyDeep; }; 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; 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>. 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 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; } >; metadata: { version: number; handlers: Array void>; }; } // Custom implementation handles this correctly: type ImmutableSchema = ReadonlyDeep; // 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> { 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 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; }; features: Map; } let config: ReadonlyDeep; function loadConfig(): ReadonlyDeep { // 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 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 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 = Depth extends 0 ? Readonly : T extends Primitive ? T : T extends Function ? T : T extends Map ? ReadonlyMap>> : T extends Set ? ReadonlySet>> : T extends (infer U)[] ? ReadonlyArray>> : { readonly [K in keyof T]: ReadonlyDeep>; }; type Prev = 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 utility type solves the nested mutability problem that built-in Readonly 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.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.