DeepReadonly in TypeScript: Freezing Nested Objects for Safer State Management
Why Immutability Matters in Modern Apps
When I started building large‑scale React and Redux projects, I quickly realized that accidental mutations were a silent source of bugs. A component would receive a configuration object, modify a nested property, and the change would propagate unexpectedly, breaking other parts of the UI. The solution isn’t just to be careful with `Object.assign`—it’s to let the type system protect us.
Enter **DeepReadonly**, a utility type that recursively marks every property of an object (and arrays) as read‑only. With it, the compiler will yell at you the moment you try to assign a value to a supposedly immutable field, giving you a safety net that feels more like a guardrail than a restriction.
A Real‑World Scenario
Imagine a dashboard that pulls a complex settings object from an API:
interface DashboardSettings {
theme: 'light' | 'dark';
widgets: {
id: string;
position: { x: number; y: number };
}[];
pagination: { page: number; limit: number };
}
// The API returns something like this (but we can’t trust it)
const rawSettings: DashboardSettings = fetchSettings();
Now suppose a helper function needs to compute a new page offset:
function getOffset(settings: DashboardSettings): number {
return settings.pagination.page * settings.pagination.limit;
}
If we accidentally write `settings.pagination.page = 2;` inside a component, TypeScript will not complain because `DashboardSettings` is fully mutable. By converting the type to `DeepReadonly
The DeepReadonly Pattern
The trick is to use **conditional types** and **mapped types** to iterate over the properties of an object and flip their mutability. The generic is recursive, handling both objects and arrays:
/**
* Recursively makes all properties of `T` read‑only.
* Supports objects, arrays, and nested structures.
*/
type DeepReadonly =
// If T is an array, map over its element type
T extends any[] ? DeepReadonlyArray :
// If T is an object (but not null), map over its keys
T extends object ? DeepReadonlyObject :
// Primitive or other types stay as they are
T;
type DeepReadonlyObject = {
readonly [K in keyof T]: DeepReadonly;
};
type DeepReadonlyArray = {
readonly [I in keyof T]: DeepReadonly;
};
The first conditional checks whether `T` is an array; if so, we delegate to `DeepReadonlyArray`. Otherwise, if it’s an object (excluding `null` and `any[]`), we use `DeepReadonlyObject`. Finally, primitives and other non‑object types are returned unchanged.
Why this matters: **type preservation**. When you apply `DeepReadonly` to a complex interface, the resulting type still matches the original shape, so existing code that only reads the data compiles without modification. The only difference is that any assignment attempt is now a type error.
Using DeepReadonly in Production
Below is a small utility I keep in a `types.ts` file of most of my projects. It also includes a helper to cast a value as readonly without changing its runtime behavior (since JavaScript objects are mutable by default, we rely on the type system).
// utils/types.ts
export type DeepReadonly =
T extends any[] ? DeepReadonlyArray :
T extends object ? DeepReadonlyObject :
T;
type DeepReadonlyObject = {
readonly [K in keyof T]: DeepReadonly;
};
type DeepReadonlyArray = {
readonly [I in keyof T]: DeepReadonly;
};
/**
* Convenience guard – tells TypeScript that a value should be treated as read‑only.
* This does NOT freeze the object at runtime; it only changes the type.
*/
export function asReadonly(value: T): DeepReadonly {
return value as DeepReadonly;
}
Now I can wrap any configuration or state object before passing it to components:
import { asReadonly } from './utils/types';
import type { DashboardSettings } from './api';
const settings: DashboardSettings = fetchSettings();
// Treat the object as read‑only for the rest of the app
const readonlySettings = asReadonly(settings);
function getOffset(settings: DashboardSettings): number {
return settings.pagination.page * settings.pagination.limit;
}
// This will cause a TypeScript error:
// readonlySettings.pagination.page = 2; // ^ Property is readonly
Because `asReadonly` returns the same value but with a stricter type, we avoid the mental overhead of creating a copy just for type safety. The runtime behavior stays the same; the compiler enforces immutability.
Best Practices and Gotchas
- Use it for external data. When you receive data from an API, a config file, or a prop, applying `DeepReadonly` signals that the shape is fixed.
-
Combine with `readonly` arrays. The pattern already handles arrays, but remember that `DeepReadonly
` still allows you to call methods like `push` at runtime because the array itself isn’t frozen. The type guard prevents assignments to indices, which is often enough. - Be careful with utility types like `Partial` and `Pick`. If you need a mutable subset of a readonly object, extract a mutable version first and then apply `DeepReadonly` to the remaining parts.
- Performance is negligible. This is purely a static‑type transformation; there is no runtime cost.
Pro tip: If you start a project with Redux Toolkit or Zustand, consider storing the initial state as `DeepReadonly
`. Even though the library may mutate the state under the hood, having a read‑only type contract helps you spot unintended side‑effects in your reducers and selectors.
By making immutability a first‑class concern at the type level, we shift many bugs from runtime to compile time. The extra mental overhead of writing `asReadonly` once per module is worth the safety net it provides.
Wrapping Up
DeepReadonly isn’t a silver bullet, but it’s a simple, powerful addition to any TypeScript toolbox. It turns “don’t mutate this” from a comment into a hard compiler error, which aligns perfectly with the philosophy of catching mistakes early. Try adding it to your next configuration object or state slice, and you’ll likely wonder how you ever coded without it.