Object.freeze(), object.seal() and object.assign() in JavaScript
Object.freeze(), Object.seal(), and Object.assign() are three static methods on Object that handle two separate concerns: freeze and seal control mutability, while assign copies properties between objects.
Theory
TL;DR
Object.freeze()locks everything: no adds, no deletes, no value changes. Think locked safe.Object.seal()fixes structure but still lets you update values. Think shrink-wrapped box.Object.assign()copies enumerable own properties from one or more sources to a target, one level deep. Think photocopier.- All three are shallow. Nested objects stay mutable regardless of what you apply to the parent.
- Decision rule: immutable config or constants → freeze. Fixed shape, changing values → seal. Clone or merge → assign.
Quick example
const base = { a: 1, b: { c: 2 } };
const frozen = Object.freeze({ ...base });
const sealed = Object.seal({ ...base });
const copied = Object.assign({}, base);
frozen.a = 99; // Fails silently (TypeError in strict mode)
frozen.b.c = 99; // Works! Nested object is not frozen
sealed.a = 99; // Works - existing values can change
sealed.d = 4; // Fails - can't add new properties
copied.a = 99; // Works - top-level copy is independent
console.log(base.a); // 1 - original unchangedThree methods, three behaviors. The frozen.b.c mutation still goes through because freeze only locks the top-level object. That trips people up constantly.
Key difference
Object.freeze() sets every property descriptor to { writable: false, configurable: false } and marks the object non-extensible. Object.seal() also marks the object non-extensible and sets configurable: false, but leaves writable: true - so existing values can still be updated, structure just cannot change. Object.assign() has nothing to do with mutability: it iterates source objects and copies their enumerable own properties to the target shallowly, using [[Get]] on the source and [[Set]] on the target.
When to use
- App-wide constants, Redux action types, or env config that should never change:
Object.freeze() - Form schema or API response shape where structure is fixed but field values update at runtime:
Object.seal() - Merge user options with defaults without mutating either:
Object.assign({}, defaults, userOptions) - Shallow clone before transforming:
Object.assign({}, obj)or the spread equivalent{ ...obj } - Fill in missing fields on an existing object from multiple sources:
Object.assign(target, source1, source2)
Comparison table
| Feature | Object.freeze() | Object.seal() | Object.assign() |
|---|---|---|---|
| Prevents property adds | Yes | Yes | No (copies to target) |
| Prevents property deletes | Yes | Yes | No |
| Prevents value changes | Yes | No | No |
| Copies properties | No | No | Yes (shallow) |
| Affects nested objects | Shallow only | Shallow only | Shallow only |
| Typical use case | Redux constants, app config | Form schemas, semi-locked state | Clone or merge, defaultProps |
Object.preventExtensions() sits below both: it only blocks adding new properties, leaving deletion and value changes fully open. Freeze is the strictest, then seal, then preventExtensions.
How the engine handles this
V8 implements Object.freeze() by calling PreventExtensions on the object, then iterating all own property descriptors and setting each to { writable: false, configurable: false }. Property access operations check these flags during Set and Delete calls in the engine's property intrinsics.
Object.seal() also calls PreventExtensions and marks each descriptor configurable: false, but skips writable. That single difference is why you can reassign sealed.a = 99 but not frozen.a = 99.
Object.assign() runs a C++ loop over each source object: for each it checks HasOwnProperty and reads via GetOwnPropertyDescriptor, then writes to the target with DefineOwnProperty. Symbols with enumerable: false and prototype-chain properties are skipped entirely. Because it uses [[Get]] rather than copying descriptors, accessor functions (getters) get invoked and their return value lands on the target as a plain data property.
One thing worth knowing from working with this in production: when you mutate a frozen property in strict mode, V8 throws a TypeError immediately. In loose mode the engine returns the object unchanged with no error at all. That silent behavior hides bugs that only surface when the data downstream is wrong.
Common mistakes
1. Assuming freeze is deep
const settings = Object.freeze({ db: { host: 'localhost' } });
settings.db.host = 'prod-server'; // Works - mutation slips through
console.log(settings.db.host); // 'prod-server'The db reference itself cannot be replaced, but the object it points to is a regular mutable object. Freeze only locked the wrapper. Fix: recursive deep freeze.
function deepFreeze(obj) {
Object.freeze(obj);
Object.getOwnPropertyNames(obj).forEach(key => {
const val = obj[key];
if (val && typeof val === 'object') deepFreeze(val);
});
return obj;
}2. Using assign for deep clone
const orig = { nest: { val: 1 } };
const copy1 = Object.assign({}, orig);
const copy2 = Object.assign({}, orig);
copy1.nest.val = 99;
console.log(copy2.nest.val); // 99 - same reference!Both copies share the same nest object because assign only copied the reference. Fix: structuredClone(obj) in Node 17+ and modern browsers, or JSON.parse(JSON.stringify(obj)) for plain serializable data.
3. Expecting assign to copy property descriptors
const source = Object.create(null, {
id: { value: 42, writable: false, enumerable: true }
});
const target = Object.assign({}, source);
target.id = 100; // Works! writable:false was not carried over
console.log(target.id); // 100Object.assign() reads the value and writes it as a plain data property. The writable: false on the source descriptor disappears. To copy full descriptors use Object.getOwnPropertyDescriptors() with Object.defineProperties().
4. Missing non-enumerable properties
const obj = { pub: 1 };
Object.defineProperty(obj, 'secret', { value: 2, enumerable: false });
const copy = Object.assign({}, obj);
console.log(copy.secret); // undefined - silently skippedAssign only touches enumerable own properties. Symbol-keyed properties that are non-enumerable are also skipped. This catches people who add metadata via defineProperty and then wonder why it vanishes after a merge.
5. Silent failure outside strict mode
const frozen = Object.freeze({ name: 'Alice' });
frozen.name = 'Bob'; // No error in loose mode
console.log(frozen.name); // 'Alice' - change ignoredAlways run application code in strict mode so the TypeError surfaces immediately. If you need to verify state programmatically, Object.isFrozen(obj), Object.isSealed(obj), and Object.isExtensible(obj) are the right tools.
Real-world usage
- React (legacy class components):
Object.assign()was used internally to mergedefaultPropswith incoming props - Redux Toolkit:
Object.freeze()on action type constants to prevent accidental mutation inside reducers - Express and body-parser:
Object.assign()to merge default middleware options with user-provided config - Node.js CLI tooling (webpack config files):
Object.seal()onprocess.envsubsets to lock schema shape - Lodash:
_.assign()as a cross-browser polyfill; separatedeepFreezeutility in the utils module
Follow-up questions
Q: What exactly differs in property descriptors between freeze and seal?
A: Object.freeze() sets writable: false and configurable: false on every own property. Object.seal() sets only configurable: false and marks the object non-extensible, but leaves writable: true. That single flag is why sealed objects accept value updates and frozen ones do not.
Q: Does Object.assign() work with null-prototype objects?
A: Yes. It copies enumerable own properties shallowly regardless of the source prototype chain. The prototype itself is never read or written.
Q: How do you check whether an object is frozen, sealed, or extensible?
A: Object.isFrozen(obj), Object.isSealed(obj), and Object.isExtensible(obj). A frozen object is always considered sealed and non-extensible too, but the reverse is not true. isFrozen returns true only when all own properties are also non-writable and non-configurable.
Q: Is there a performance cost to freezing objects?
A: Freezing is a one-time operation with negligible cost. The concern is calling freeze or seal inside hot loops. For primitive values const is enough - freeze is only meaningful for objects.
Q: (Senior) Why does Object.assign() invoke getters from the source instead of copying the accessor descriptor?
A: The spec defines Object.assign() using [[Get]] on each source property and [[Set]] on the target. That means getters are called and their return value is written as a plain data property on the target. To copy the full accessor descriptor use Object.getOwnPropertyDescriptors() combined with Object.create() or Object.defineProperties().
Q: (Senior) In V8, why does modifying a frozen object throw in strict mode but not in loose mode?
A: The engine's Set operation checks the writable flag on the descriptor. In strict mode, a failed write calls the Throw path and produces a TypeError. In sloppy mode semantics, the same check fails but the Throw path is skipped - the engine returns without mutation and without error. This is specified behavior, not a V8 quirk.
Examples
Merging user config with app defaults
This pattern appears in Express middleware setup, React app configuration, and CLI tools.
const defaults = { theme: 'light', apiKey: 'default-key', debug: false };
const userConfig = { theme: 'dark' }; // Partial override
const config = Object.assign({}, defaults, userConfig);
// { theme: 'dark', apiKey: 'default-key', debug: false }
// Lock the result so no module can accidentally mutate it
const frozenConfig = Object.freeze(config);
frozenConfig.theme = 'light'; // Fails - config is safe across rendersLater sources overwrite earlier ones, so userConfig.theme wins over defaults.theme. The empty {} as the first argument ensures defaults itself is never modified - Object.assign() mutates the target, and here the target is the fresh empty object.
Shared nested reference pitfall
This is the most common real bug that comes from using assign for cloning.
const template = {
name: 'untitled',
meta: { version: 1, tags: [] }
};
const doc1 = Object.assign({}, template);
const doc2 = Object.assign({}, template);
doc1.meta.version = 2;
doc1.meta.tags.push('draft');
console.log(doc2.meta.version); // 2 - same object!
console.log(doc2.meta.tags); // ['draft'] - same array!Both documents share the same meta object because assign copied only the reference, not the object. structuredClone(template) solves this. For older environments JSON.parse(JSON.stringify(template)) works on plain data, but strips functions, Dates get stringified, and undefined values disappear.
Object.seal() for a form schema
const formSchema = Object.seal({
username: '',
email: '',
role: 'viewer'
});
formSchema.username = 'alice'; // Works - values can change
formSchema.role = 'admin'; // Works
formSchema.password = '123'; // Fails - shape is fixed
console.log(formSchema);
// { username: 'alice', email: '', role: 'admin' }
// Check state at runtime
console.log(Object.isSealed(formSchema)); // true
console.log(Object.isFrozen(formSchema)); // falseObject.seal() is the right choice when a config object has a known structure that should not grow but its field values genuinely need to update at runtime. Less common than freeze, but using freeze here would block formSchema.username = 'alice' and that is not what you want.
Property descriptor edge case with assign
This one trips up senior developers who assume assign preserves descriptor flags.
const source = Object.create(null, {
id: { value: 42, writable: false, enumerable: true },
token: { get: () => 'secret', enumerable: true }
});
const target = Object.assign({}, source);
target.id = 100; // Works - writable:false was not copied
console.log(target.id); // 100
console.log(target.token); // 'secret' - getter was called, value stored
// Now freeze the copy
const frozen = Object.freeze(target);
frozen.id = 999; // Fails - freeze applied writable:false here
console.log(frozen.id); // 100Object.assign() invoked the getter on source.token and stored the string result as a plain data property on target. If you need to copy the actual getter, use Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) instead.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.