~/blog

Object.freeze is shallow, and a frozen Map still accepts writes

published

#javascript#immutability#debugging

TL;DR

Object.freeze freezes exactly one level: the own properties of the object you passed. Anything those properties point at is a different object and stays fully mutable. Worse, freezing a Map, Set or Date does nothing useful at all — their contents live in internal slots rather than properties, so set, add and setTime keep working on a frozen instance. Object.isFrozen returns true the whole time.

The problem

You freeze a config object, ship it as the immutable defaults, and something reaches in and changes it anyway.

const config = Object.freeze({
  name: 'api',
  retries: 3,
  db: { host: 'localhost', port: 5432 },
  tags: ['a', 'b'],
});

console.log('isFrozen(config):', Object.isFrozen(config));
console.log('isFrozen(config.db):', Object.isFrozen(config.db));

config.db.host = 'evil.example';
config.tags.push('c');
console.log('after nested writes:', JSON.stringify(config));

On Node v26.2.0:

isFrozen(config): true
isFrozen(config.db): false
after nested writes: {"name":"api","retries":3,"db":{"host":"evil.example","port":5432},"tags":["a","b","c"]}

Both writes went through. No error, no warning. The object still reports itself as frozen, because it is — config has exactly the four own properties it started with, and none of them was reassigned. What changed was the inside of two other objects that config merely points at.

Only the top level is actually protected:

config.retries = 99;
// TypeError: Cannot assign to read only property 'retries' of object '#<Object>'

Why it happens

Object.freeze runs the spec operation SetIntegrityLevel with level frozen. That does two things, both of them local:

  1. Marks the object non-extensible, so no new properties can be added.
  2. Walks the object’s own property keys and sets each data property to writable: false, configurable: false.

There is no recursion in that algorithm and no hook that would make it recurse. A property holding an object stores a reference; freezing makes the reference unchangeable, not the thing at the other end of it. config.db will always point at the same object — that object’s own fields were never touched.

The part that catches people out: Map, Set and Date

Map does not keep its entries in properties. It keeps them in an internal slot ([[MapData]]) that the property machinery cannot see, so making the object non-extensible and its own properties read-only locks nothing:

const m = Object.freeze(new Map([['a', 1]]));
m.set('b', 2);
m.delete('a');
console.log([...m.entries()], Object.isFrozen(m));
[ [ 'b', 2 ] ] true

The entry was added, the original entry was deleted, and Object.isFrozen still says true. Set behaves the same way via add, and so does Date:

const d = Object.freeze(new Date('2020-01-01T00:00:00Z'));
d.setTime(0);
console.log(d.toISOString()); // 1970-01-01T00:00:00.000Z

Arrays are the exception that behaves sensibly — a frozen array is non-extensible, so push throws:

TypeError: Cannot add property 1, object is not extensible

Strict mode decides whether you get told

The TypeError on a top-level write only happens in strict mode. ES modules and class bodies are always strict; a plain CommonJS file without 'use strict' is not, and there the failed write is discarded in silence:

// config.cjs — sloppy mode
const config = Object.freeze({ retries: 3 });
config.retries = 99;
console.log('retries =', config.retries); // retries = 3

Same on Node v26.2.0: no throw, and retries is still 3. The freeze worked; you just never found out that something tried to fight it. That is the shape that hides in a codebase for months — the write that “obviously happened” never did.

What to do

Freeze recursively when you actually mean immutable:

const deepFreeze = (obj) => {
  for (const value of Object.values(obj)) {
    if (value && typeof value === 'object') deepFreeze(value);
  }
  return Object.freeze(obj);
};

const cfg = deepFreeze({ db: { host: 'localhost' }, tags: ['a'] });
console.log(Object.isFrozen(cfg.db)); // true
cfg.db.host = 'evil.example';
console.log(cfg.db.host);             // localhost

Three things to keep in mind when you use it:

ConcernWhat to do
CyclesThe version above recurses forever on a.self = a. Track visited objects in a WeakSet if your data can contain cycles.
Map / Set / DateCannot be frozen meaningfully. Convert to a plain object or array at the boundary, or wrap in a read-only facade that throws on mutation.
GettersObject.values invokes them. If a getter is expensive or has side effects, iterate Object.getOwnPropertyNames and read descriptors instead.

And two habits that avoid needing the freeze at all:

Caveats

References