Why Object.freeze does not carry the load
const config = Object.freeze({
db: { host: 'localhost' },
});
config.db.host = 'hacked'; // works silently in non-strict mode
Freeze is shallow. Nested objects are still mutable. Deep freeze exists but is not free and does not play well with TypeScript types. For most codebases, freeze is not the answer.
The discipline answer
Treat data as immutable by convention. Never mutate function arguments. Always return new objects from reducers and transforms. Use const everywhere — not because it prevents mutation (it doesn't), but because it signals intent.
// bad
function addItem(cart, item) {
cart.items.push(item);
return cart;
}
// good
function addItem(cart, item) {
return { ...cart, items: [...cart.items, item] };
}
This reads clearly. Bugs from shared mutable state disappear. But it gets verbose for deep structures:
return {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: 'dark',
},
},
};
Enter Immer
Immer lets you write mutating code against a draft and hands you an immutable result:
import { produce } from 'immer';
const next = produce(state, (draft) => {
draft.user.preferences.theme = 'dark';
});
Under the hood it uses structural sharing — unchanged branches are reused, not copied. The new state is immutable, the old state is untouched, and you wrote three lines instead of ten.
Redux Toolkit uses Immer by default for exactly this reason. Zustand supports it as an opt-in middleware. If you are hand-rolling nested spreads, switch.
TypeScript readonly as a seatbelt
Types can enforce immutability at the boundary:
type Config = {
readonly db: {
readonly host: string;
readonly port: number;
};
};
Readonly<T> and ReadonlyArray<T> help but do not recurse. type-fest's ReadonlyDeep does. Type-level immutability does not stop JSON.parse from producing a mutable object at runtime, but it stops your code from mutating it by accident.
When mutability is the right call
Performance-critical paths where you own the data, short-lived, and never shared: mutate freely. Building up a 100k-item array? Push, do not spread. A tight inner loop? i++ is fine. Immutability is about avoiding shared mutable state, not about banning assignment.
The working rule
Data that crosses a boundary — function argument, state update, API response — is immutable. Inside a function you own, mutate if it is clearer. Use Immer for nested updates. Use readonly types for public APIs. Skip Object.freeze entirely except in tests where you want a hard guardrail against accidental mutation.
