DeepReadonly Utility Type
`Readonly<T>` only freezes the top level, which is rarely enough for state objects passed across module boundaries. `DeepReadonly<T>` walks the type tree and marks every property `readonly` at every depth. This snippet builds the recursive form, refines it for arrays so they become `ReadonlyArray<T>`, and pairs it with `Object.freeze` so the runtime matches the type-level guarantee.
501 views
9
type DeepReadonlyBasic<T> = T extends object ? { readonly [K in keyof T]: DeepReadonlyBasic<T[K]> } : T;
interface AppState {
user: { name: string; tags: string[] };
counter: number;
}
const s = { 'user': { 'name': 'Ada' }, 'counter': 1 } as DeepReadonlyBasic<AppState>;
console.log(s.counter);
// At compile time: s.counter = 2; would be a TypeScript error.Adding readonly inside the mapped type rebuild marks every property on the new shape as immutable. The conditional T extends object ? ... : T stops the recursion at primitives, just like the DeepPartial pattern. The cast in the runtime sentinel is for testing only; in real code the value comes from a function that returns DeepReadonlyBasic<T> so the compiler tracks immutability transitively. Note that readonly is a compile-time check only; it does not freeze the object at runtime, which the third accordion fixes.
type DeepReadonlyArr<T> = T extends Array<infer U>
? ReadonlyArray<DeepReadonlyArr<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonlyArr<T[K]> }
: T;
interface Cart {
items: Array<{ id: string; qty: number }>;
note: string;
}
const cart = { 'items': [{ 'qty': 1 }], 'note': 'gift' } as DeepReadonlyArr<Cart>;
console.log(cart.note);
console.log(cart.items.length);The basic shape converts arrays to a record-of-readonly-properties, which loses the [] index signature plus all the array methods. Branching on Array<infer U> first preserves the array shape and converts it to ReadonlyArray<...>, which removes mutating methods (push, pop, splice) from the type while keeping map, filter, etc. The infer U in the conditional matches the element type. Tuples follow the same path because Array<infer U> matches them, though for a strict tuple-preserving variant you can pattern-match T extends [infer A, ...infer R].
type DeepReadonly<T> = T extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
function deepFreeze<T>(value: T): DeepReadonly<T> {
if (value === null || typeof value !== 'object') return value as DeepReadonly<T>;
if (Array.isArray(value)) {
for (const item of value) deepFreeze(item);
return Object.freeze(value) as unknown as DeepReadonly<T>;
}
for (const k of Object.keys(value as object)) {
deepFreeze((value as Record<string, unknown>)[k]);
}
return Object.freeze(value) as DeepReadonly<T>;
}
const frozen = deepFreeze({ 'count': 1, 'tags': ['a', 'b'] });
try { (frozen as Record<string, number>).count = 2; } catch (e) { console.log('strict mode threw'); }
console.log(frozen.count);TypeScript's readonly is erased at runtime, so a determined caller can still mutate the object via a cast. Walking the value tree and calling Object.freeze on every node enforces the contract at runtime: in strict mode the assignment throws, in sloppy mode it silently no-ops. The signature <T>(value: T): DeepReadonly<T> is the headline trick because callers get the static guarantee and the dynamic guarantee from one call. Do not freeze hot paths in performance-sensitive code; freezing has measurable overhead and is best applied at module boundaries (configuration, store snapshots, public exports).
