DeepReadonly: Enforce Immutable Configuration Objects in TypeScript
Why Immutability Is a Silent Safety Net
When I started building a design‑system library, I quickly realized that theme objects—colors, spacing, typography—needed to stay constant after they were loaded. A developer could accidentally mutate a color value, and the change would propagate silently across the entire UI, making bugs hard to trace. The solution I adopted was to make those objects truly immutable at the type level, not just at runtime.
Enter **DeepReadonly**, a recursive utility type that marks every property of an object (and nested objects) as readonly. By applying it, TypeScript itself becomes your guard against accidental mutations, giving you confidence that the configuration you ship is exactly what you intended.
The Problem in Plain Terms
Standard TypeScript utilities like `Readonly
type Theme = {
colors: {
primary: string;
secondary: string;
};
spacing: number;
};
const theme: Readonly = { … };
you can still do `theme.colors.primary = '#ff0000'`. The nested `colors` object is mutable, and the type checker won’t complain. In a large codebase, that small loophole can lead to subtle bugs that are expensive to track down.
The DeepReadonly Utility
The fix is a generic that recurses over object keys. The implementation is concise and works for arrays, unions, and even mapped types.
type DeepReadonly = T extends (infer R)[] ? ReadonlyArray > : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T;
Let’s break it down:
- If T is an array, we map each element recursively and turn the result into a
ReadonlyArray. - If T is a plain object, we create a new object type with all keys marked
readonlyand each value transformed by DeepReadonly again. - Anything else (primitives, functions, etc.) is returned as‑is.
Because the type is recursive, it works for arbitrarily deep shapes without any extra boilerplate.
Real‑World Scenario: A Theme Loader
In my UI library, themes are loaded from a JSON file and then exported to consumers. The goal was to guarantee that once a theme is exported, its values cannot be altered.
// theme.json (example)
{
"colors": {
"primary": "#0066cc",
"secondary": "#ff6600"
},
"spacing": {
"unit": 8,
"large": 32
}
}
The loader parses the JSON and applies DeepReadonly to the resulting object:
import { readFileSync } from 'fs';
import * as path from 'path';
type RawTheme = {
colors: {
primary: string;
secondary: string;
};
spacing: {
unit: number;
large: number;
};
};
type Theme = DeepReadonly;
function loadTheme(): Theme {
const raw = JSON.parse(
readFileSync(path.join(__dirname, 'theme.json'), 'utf8')
) as RawTheme;
// DeepReadonly is a type‑only utility, so we only need to cast for TypeScript.
return raw as Theme;
}
export const theme = loadTheme();
Now, any attempt to modify `theme.colors.primary` triggers a TypeScript error, and the type system documents the immutability for any IDE that inspects the type.
Tip: If you need to update a theme at runtime (e.g., for a dark‑mode toggle), consider creating a new immutable object rather than mutating the existing one. This keeps the API predictable and aligns with functional programming principles.
Tips, Gotchas, and Extensions
-
Handling Dates and Functions: The current definition treats functions as mutable values. If you want them readonly as well, you can extend the union:
T extends (...args: any[]) => any ? T : …. -
Circularity: If your configuration contains circular references (rare in JSON), the recursion will blow the stack. In such cases, you can reach for a library like lodash-es’s
deepFreezeor implement a WeakMap‑based approach. -
Performance: Type‑level recursion has negligible runtime cost because it’s erased. However, if you need to apply the immutability at runtime as a safety net, you can pair it with
Object.freezefor an extra layer of protection.
When to Use DeepReadonly
Any configuration object that should remain constant after initialization benefits from this pattern. Typical use cases include:
- Theme or branding definitions.
- API request schemas that are built once and reused.
- State shapes in a Redux store that are frozen after the initial reducer.
- Static lookup tables (e.g., error messages, validation rules).
Wrapping Up
DeepReadonly is a small but powerful addition to your TypeScript toolkit. By recursively marking every property as readonly, you eliminate a class of bugs that stem from accidental mutations, and you give your teammates clear documentation of the object’s immutability through the type system.
I started using it when I needed a reliable way to protect theme values, and now it’s part of every configuration object I export. If you’re tired of seeing those “property is possibly missing” errors when you try to assign to a nested property, give DeepReadonly a try. It’s a few lines of code, and the peace of mind it brings is priceless.
Happy coding, and may your types stay immutable!