Freezing & Immutability
JavaScript objects are mutable by default — any code holding a reference can add, change, or delete properties at will. That flexibility is convenient, but it also makes bugs hard to trace and shared state hard to reason about. JavaScript ships three built-in methods — Object.freeze, Object.seal, and Object.preventExtensions — that lock objects down to varying degrees, and understanding their (shallow) reach is the key to using them correctly.
const is not immutability
A common misconception is that declaring an object with const makes it immutable. It does not. const only prevents reassigning the binding — the object it points to can still be mutated freely.
const user = { name: "Ada" };
user.name = "Grace"; // allowed — we mutate the object
console.log(user.name);
// user = {}; // TypeError: Assignment to constant variable.
Output:
Grace
To actually protect the contents of an object, you need one of the lock methods below.
Object.freeze
Object.freeze(obj) makes an object fully immutable at the top level: you cannot add, delete, or change any of its existing properties, and existing properties become non-configurable and non-writable. It returns the same object (now frozen), so you can wrap a literal inline.
const config = Object.freeze({
apiUrl: "https://api.example.com",
retries: 3,
});
config.retries = 5; // silently ignored (throws in strict mode)
config.timeout = 1000; // ignored — no new properties
delete config.apiUrl; // ignored — cannot delete
console.log(config);
console.log(Object.isFrozen(config));
Output:
{ apiUrl: 'https://api.example.com', retries: 3 }
true
In non-strict code, mutations to a frozen object fail silently. Inside a module or any
"use strict"context, the same operations throw aTypeError, which is usually what you want — fail loudly rather than swallow the bug.
Object.seal
Object.seal(obj) is a middle ground: you cannot add or delete properties, but you can still change the values of existing writable properties. Properties become non-configurable but remain writable.
const account = Object.seal({ balance: 100 });
account.balance = 250; // allowed — value change is fine
account.owner = "Lin"; // ignored — cannot add properties
delete account.balance; // ignored — cannot delete
console.log(account, Object.isSealed(account));
Output:
{ balance: 250 } true
Object.preventExtensions
The lightest lock. Object.preventExtensions(obj) only blocks adding new properties. Existing properties can still be changed and deleted.
const point = Object.preventExtensions({ x: 1, y: 2 });
point.x = 99; // allowed
delete point.y; // allowed
point.z = 3; // ignored — cannot extend
console.log(point, Object.isExtensible(point));
Output:
{ x: 99 } false
Comparing the three methods
| Operation | preventExtensions | seal | freeze |
|---|---|---|---|
| Add new property | ❌ | ❌ | ❌ |
| Delete property | ✅ | ❌ | ❌ |
| Change existing value | ✅ | ✅ | ❌ |
| Reconfigure property | ✅ | ❌ | ❌ |
| Check with | isExtensible | isSealed | isFrozen |
Each level is strictly stronger than the one above it: a frozen object is also sealed and non-extensible.
Freeze is shallow
The most important gotcha: all three methods are shallow. Object.freeze only locks the object’s own top-level properties. If a property holds another object or array, that nested value remains fully mutable.
const settings = Object.freeze({
theme: "dark",
features: ["search", "export"],
});
settings.theme = "light"; // ignored (frozen)
settings.features.push("share"); // works! nested array is NOT frozen
console.log(settings.features);
Output:
[ 'search', 'export', 'share' ]
A deep freeze recipe
To make an object deeply immutable, recursively freeze every nested object and array. Walk the values, freeze any that are objects, then freeze the root.
function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return Object.freeze(obj);
}
const state = deepFreeze({
user: { name: "Ada", roles: ["admin"] },
});
state.user.name = "Grace"; // ignored
state.user.roles.push("dev"); // ignored
console.log(state.user);
Output:
{ name: 'Ada', roles: [ 'admin' ] }
The Object.isFrozen guard prevents infinite recursion on objects that contain circular references.
Immutable update patterns
Freezing protects existing data, but real applications still need to produce new state. The idiomatic approach is to derive a fresh object with the spread operator rather than mutating in place — exactly the pattern used by Redux and modern state libraries.
const original = Object.freeze({ count: 1, label: "a" });
// Create a new object instead of mutating
const updated = { ...original, count: original.count + 1 };
console.log(original.count, updated.count);
Output:
1 2
For collections, prefer non-mutating array methods — map, filter, concat, and the newer toSorted/toReversed (ES2023) — over push, splice, or sort, all of which mutate in place.
Best Practices
- Reach for
Object.freezeon constants, configuration, and enum-like lookup tables so accidental writes fail fast. - Remember that
constguards the binding, not the value — pair it withfreezewhen you need true immutability. - Always run your code in strict mode (modules are strict by default) so illegal mutations throw instead of failing silently.
- Use a recursive
deepFreezehelper when an object’s nested structure must also be protected; shallow freeze is rarely enough. - Treat state as immutable by building new objects/arrays with spread and non-mutating methods rather than editing in place.
- Avoid freezing very large or hot-path objects unnecessarily — frozen objects can prevent certain engine optimizations.
- Prefer
toSorted,toReversed, andwithover their mutating counterparts when working with arrays you intend to keep immutable.