Suggest an editImprove this articleRefine the answer for “Object.freeze(), object.seal() and object.assign() in JavaScript”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Object.freeze()** locks an object completely: no adds, deletes, or value changes. **Object.seal()** fixes the structure but allows updating existing values. **Object.assign()** copies enumerable own properties from source objects to a target, shallowly. ```javascript Object.freeze({ a: 1 }).a = 2; // Fails Object.seal({ a: 1 }).a = 2; // Works Object.assign({}, { a: 1 }, { b: 2 }); // { a: 1, b: 2 } ``` **Key:** All three are shallow - nested objects remain mutable regardless.Shown above the full answer for quick recall.Answer (EN)Image**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 ```javascript 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 unchanged ``` Three 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** ```javascript 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. ```javascript 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** ```javascript 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** ```javascript 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); // 100 ``` `Object.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** ```javascript const obj = { pub: 1 }; Object.defineProperty(obj, 'secret', { value: 2, enumerable: false }); const copy = Object.assign({}, obj); console.log(copy.secret); // undefined - silently skipped ``` Assign 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** ```javascript const frozen = Object.freeze({ name: 'Alice' }); frozen.name = 'Bob'; // No error in loose mode console.log(frozen.name); // 'Alice' - change ignored ``` Always 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 merge `defaultProps` with 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()` on `process.env` subsets to lock schema shape - Lodash: `_.assign()` as a cross-browser polyfill; separate `deepFreeze` utility 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. ```javascript 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 renders ``` Later 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. ```javascript 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 ```javascript 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)); // false ``` `Object.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. ```javascript 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); // 100 ``` `Object.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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.