JAVASCRIPT / OBJECTS AND PROPERTY MECHANICS
Sealing, freezing, and preventing extensions
Choose between Object.preventExtensions, Object.seal, and Object.freeze, and predict exactly which writes, deletes, and additions each one blocks.
What you will learn
- Pick preventExtensions, seal, or freeze by the exact mutation you need to block
- Predict whether a blocked write silently no-ops or throws a TypeError
- Check lock state with Object.isExtensible, Object.isSealed, and Object.isFrozen
- Write a recursive deepFreeze, since every one of these locks is one level deep
Understanding Sealing, freezing, and preventing extensions
These three functions are not separate features but one ladder. Every object carries an internal extensible flag, and Object.preventExtensions clears it, which stops new own properties from ever appearing. Object.seal clears that flag and additionally stamps configurable: false onto every existing own property. Object.freeze does both of those and additionally stamps writable: false onto every own data property, so the differences between the three fall out of attributes rather than from three unrelated rules.
Knowing which attribute governs which operation lets you predict the behaviour instead of memorising a table. Deletion is governed by configurable, not by extensibility, which is why delete obj.host still succeeds on a merely non-extensible object. Assignment is governed by writable, which is why a sealed object still accepts obj.role = 'guest'. An accessor property has no writable attribute at all, so freeze cannot switch a setter off: a frozen object whose setter writes to a closure variable or to another object keeps working.
Two practical consequences follow. A rejected operation returns quietly in a non-strict script and throws a TypeError in strict mode, modules, and class bodies, so the same code looks harmless in one file and explodes in another. And all three functions touch only the own properties of the one object you passed, so nested objects and arrays stay fully mutable; there is also no reverse operation, because the extensible flag and the configurable attribute can only be turned off.
// Plain script (non-strict): rejected writes fail silently here.
const config = { host: 'localhost', port: 8080 };
Object.preventExtensions(config);
config.port = 9090; // ok: property exists and is still writable
config.debug = true; // rejected: the object is no longer extensible
delete config.host; // ok: deleting depends on configurable, not extensible
console.log(config, Object.isExtensible(config));
const user = { name: 'Ada', role: 'admin' };
Object.seal(user);
user.role = 'guest'; // ok: seal leaves writable alone
delete user.name; // rejected: own properties are now non-configurable
console.log(user, Object.isSealed(user), Object.isFrozen(user));
const limits = { max: 10, nested: { max: 10 } };
Object.freeze(limits);
limits.max = 99; // rejected: writable is now false
limits.nested.max = 99; // ok: freeze never reaches nested objects
console.log(limits, Object.isFrozen(limits), Object.isFrozen(limits.nested));preventExtensions, seal, and freeze are one ladder built from the extensible flag plus the configurable and writable attributes, and every rung is shallow and permanent.
Worked examples
Strict mode turns silence into a TypeError
The same frozen-object write is ignored in a sloppy script and thrown in a strict one.
'use strict';
const settings = Object.freeze({ theme: 'dark' });
try {
settings.theme = 'light';
} catch (err) {
console.log(err.constructor.name, settings.theme);
}
try {
Object.defineProperty(settings, 'theme', { value: 'light' });
} catch (err) {
console.log(err.constructor.name);
}Example explained
Line 1The 'use strict' directive is what makes the rejected assignment throw; without it that line is just ignored.
Line 2settings.theme still prints dark, so the throw replaced the write rather than happening after it.
Line 3Object.defineProperty throws on a frozen property in any mode, because it reports failure directly instead of through a strictness check.
Line 4Logging err.constructor.name keeps the output stable, since the message text differs between engines.
const locks the name, freeze locks the object
Shows that binding immutability and object immutability are independent of each other.
const point = Object.freeze({ x: 1, y: 2 });
try {
point = { x: 9, y: 9 };
} catch (err) {
console.log('rebind:', err.constructor.name);
}
let loose = { x: 1, y: 2 };
Object.freeze(loose);
loose = { x: 9, y: 9 };
console.log('rebound to', loose);
console.log(Object.isFrozen(loose));Example explained
Line 1Reassigning point throws because const forbids rebinding the name, which has nothing to do with the freeze.
Line 2loose is a let binding, so it can be pointed at a new object even though the old object was frozen.
Line 3Object.isFrozen(loose) is false: the freeze belonged to the object that was discarded, not to the variable.
Freezing a whole tree
A recursive helper reaches nested objects and arrays that Object.freeze alone leaves mutable.
function deepFreeze(obj) {
for (const value of Object.values(obj)) {
if (value !== null && typeof value === 'object') deepFreeze(value);
}
return Object.freeze(obj);
}
const state = deepFreeze({ user: { tags: ['a'] } });
console.log(Object.isFrozen(state.user), Object.isFrozen(state.user.tags));
try {
state.user.tags.push('b');
} catch (err) {
console.log(err.constructor.name, state.user.tags.length);
}Example explained
Line 1Object.values plus a typeof check finds the nested object and the array, since arrays are objects too.
Line 2Children are frozen before the parent, so the whole tree is locked by the time the outer call returns.
Line 3push throws even in a non-strict script, because Array.prototype.push performs a throwing write internally.
Line 4length is still 1, confirming the element was never added before the throw.
Important notes
Object.preventExtensions({}) makes both Object.isSealed and Object.isFrozen return true: with no own properties there is nothing left to lock, so the predicates are satisfied vacuously.
Once an object is non-extensible, Object.setPrototypeOf on it throws in strict mode, but the prototype object itself stays mutable unless you freeze that too.
Common mistakes
Writing const state = { count: 0 } and assuming it is protected; const only blocks rebinding, so state.count++ still mutates the object.
Freezing a top-level state object and continuing to call state.items.push(item), which succeeds because the freeze never reached the array.
Testing in a non-strict console, seeing no error from a blocked assignment, and concluding freeze did not work, or worse, shipping a write that silently does nothing and surfaces later as stale data.
Try it yourself
Change, predict, then run
In a browser console create const state = Object.freeze({ count: 0, tags: ['a'] }), then attempt state.count = 1, state.extra = 1, and state.tags.push('b'), and log state to see which single attempt actually changed the data. Repeat the same three lines inside a function that starts with 'use strict' and note which ones now throw.
Open the JavaScript workspaceCheck your understanding
You call Object.seal on an object whose only own property, mode, came from a plain object literal. Which operation still changes that object?
- delete obj.mode
- obj.newKey = 1
- obj.mode = 'off'
- Object.defineProperty(obj, 'mode', { enumerable: false })
Show answer
Seal marks the object non-extensible and sets configurable: false on each own property, but it never touches writable, so assigning to an existing key still lands. Redefining the descriptor is the tempting answer because it neither adds nor removes a key, but non-configurable also locks the attributes themselves, so that call fails; the delete and the new key are blocked by configurable and extensibility respectively.