Skip to main content

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

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

FeatureObject.freeze()Object.seal()Object.assign()
Prevents property addsYesYesNo (copies to target)
Prevents property deletesYesYesNo
Prevents value changesYesNoNo
Copies propertiesNoNoYes (shallow)
Affects nested objectsShallow onlyShallow onlyShallow only
Typical use caseRedux constants, app configForm schemas, semi-locked stateClone 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.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?