Freezing and Immutability

Mental model: Freezing locks one object, not the objects it points at.

Level: intermediate · about 10 minutes

const settings = Object.freeze({ theme: 'dark', retries: 3 });

try {
  settings.theme = 'light';   // strict mode here, so this throws
} catch (err) {
  console.log(err.name);      // 'TypeError'
}

console.log(settings.theme);      // 'dark'
console.log(Object.isFrozen(settings)); // true

A frozen object rejects every change to its own properties.

Object.freeze makes every own property non-writable and non-configurable, and prevents new properties being added. It is the strongest of the three built in locks and the only one that stops values changing.

CallAdd new keysDelete keysChange existing values
Object.preventExtensions(o)noyesyes
Object.seal(o)nonoyes
Object.freeze(o)nonono
const sealed = Object.seal({ count: 0 });

sealed.count = 5;          // allowed
console.log(sealed.count); // 5

console.log(Object.isSealed(sealed), Object.isFrozen(sealed)); // true false

Sealed: the shape is fixed, the values are not.

Freezing is shallow too

const config = Object.freeze({ db: { host: 'localhost' } });

config.db.host = 'evil.example';   // no error, no protection
console.log(config.db.host);       // 'evil.example'

console.log(Object.isFrozen(config), Object.isFrozen(config.db)); // true false

Deep freeze, in five lines

function deepFreeze(value) {
  if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
  Object.freeze(value);                                   // freeze first, so cycles terminate
  for (const key of Object.keys(value)) deepFreeze(value[key]);
  return value;
}

const locked = deepFreeze({ db: { host: 'localhost', ports: [5432] } });
console.log(Object.isFrozen(locked.db), Object.isFrozen(locked.db.ports)); // true true

Why the same write is silent in one file and throws in another

Sloppy mode: silent

// a classic <script> with no 'use strict'
const o = Object.freeze({ a: 1 });
o.a = 2;        // ignored, no error
console.log(o.a); // 1

Strict mode and modules: throws

// any ES module, or after 'use strict'
const o = Object.freeze({ a: 1 });
o.a = 2;        // TypeError

ES modules and class bodies are strict automatically, so in modern code a rejected write throws. The silent version is the legacy behaviour, and it is the reason people believe freeze "did not work".

Immutable data discipline

  • Freeze the things that are meant to be constant: configuration, lookup tables, default values.
  • Treat everything else as immutable by convention: return new objects from updates rather than mutating arguments.
  • Prefer map, filter and spread over push, sort and property assignment in shared data.
  • Freeze in development and skip it in production if the cost matters. Freezing large hot objects is not free.
const state = Object.freeze({ count: 0, tags: Object.freeze(['a']) });

const next = Object.freeze({ ...state, count: state.count + 1, tags: [...state.tags, 'b'] });

console.log(state.count, next.count);  // 0 1
console.log(state.tags, next.tags);    // ['a'] ['a', 'b']

The update pattern that needs no freezing at all.

const o = Object.freeze({ nested: { n: 1 }, top: 1 });
o.nested.n = 2;
console.log(o.nested.n, Object.isFrozen(o.nested));

Freeze applies to the properties of o only. The nested object is a separate object that was never frozen, so writing to it succeeds and Object.isFrozen(o.nested) is false.

Try it yourself

Three locks, side by side

const extendable = { a: 1 };
const sealed = Object.seal({ a: 1 });
const frozen = Object.freeze({ a: 1, nested: { b: 2 } });

extendable.b = 2;
sealed.a = 99;
frozen.nested.b = 99;      // nested objects are not protected

console.log(extendable, sealed, frozen);
console.log(Object.isExtensible(extendable), Object.isSealed(sealed), Object.isFrozen(frozen));

Try deleting a key from each object. Then freeze the nested array and watch push throw.

Exercises

Write deepFreeze

Write deepFreeze(value) which freezes an object and everything reachable from its own enumerable properties, including arrays, and returns the same value. Primitives pass straight through. It must not loop forever on a circular structure.

Check yourself

What does this log?
2 — Sealing fixes the shape, not the values, so the assignment succeeds and a becomes 2. The delete is rejected: in strict mode it throws, in sloppy mode it returns false. Either way the value 2 remains readable.
Why does Object.freeze sometimes appear to do nothing?
In sloppy mode a rejected write fails silently, and freezing is shallow so nested objects stay writable — Both reasons bite in practice. Modules are strict so writes throw there, but nested objects are never frozen for you. Use a deep freeze if you need the whole tree locked.
You want a configuration object nobody can modify, and its values must stay private. Is freeze enough?
No, freeze prevents writes but reads are still open. Use a closure or private fields for privacy — Freezing is about mutation, not visibility. Anything on a frozen object can still be read, iterated and serialised. Privacy comes from not exposing the value in the first place.

Common mistakes

  • Assuming Object.freeze is deep. It locks one level only.
  • Testing freeze in sloppy mode, seeing no error, and concluding it does not work.
  • Confusing freeze with privacy. Reads are unaffected.

Takeaways

  • preventExtensions stops additions, seal also stops deletions, freeze also stops writes.
  • All three are shallow. Recurse yourself if you need the whole tree.
  • A rejected write throws in strict mode and modules, and is silent in sloppy mode.
  • Immutability is mostly a discipline: return new objects instead of editing shared ones.