Blog

Object.freeze in JavaScript Is Shallow

Object.freeze stops properties being added, removed or changed. It is often used on config objects and constants.

It only reaches one level deep. A frozen object holding another object protects the reference, not the thing at the end of it.

One level works

Writing to a frozen property throws in strict mode, which includes all module code.

'use strict';

// freeze stops top-level writes
const config = Object.freeze({ port: 8080, db: { host: 'localhost' } });
try { config.port = 9090; }
catch (err) { console.log(err.constructor.name + ':', err.message); }
console.log('port ->', config.port);

It prints:

TypeError: Cannot assign to read only property 'port' of object '#<Object>'
port -> 8080

The value is unchanged.

The level below does not

The nested object is a different object, and nobody froze it.

// but nested objects are untouched
config.db.host = 'production-server';
console.log('db.host ->', config.db.host, '| frozen?', Object.isFrozen(config.db));

It prints:

db.host -> production-server | frozen? false

No error, and the value changed. This is the bug that hides in shared config.

Freeze the whole tree

Walk the object and freeze as you go. The isFrozen check at the top is what stops the recursion from looping forever.

// deep freeze
function deepFreeze(value) {
  if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
  Object.freeze(value);
  for (const key of Reflect.ownKeys(value)) deepFreeze(value[key]);
  return value;
}
const safe = deepFreeze({ port: 8080, db: { host: 'localhost', pool: { max: 5 } } });
try { safe.db.pool.max = 100; }
catch (err) { console.log(err.constructor.name + ':', err.message); }
console.log('max ->', safe.db.pool.max);

It prints:

TypeError: Cannot assign to read only property 'max' of object '#<Object>'
max -> 5

Reflect.ownKeys is used instead of Object.keys so that symbol keyed and non enumerable properties are covered too.

Cycles and arrays

An object that points at itself is fine, because the second visit sees a frozen object and stops.

// it handles cycles because frozen objects are skipped
const loop = { name: 'a' };
loop.self = loop;
deepFreeze(loop);
console.log('frozen ->', Object.isFrozen(loop));

// arrays too
const list = deepFreeze([{ id: 1 }, { id: 2 }]);
try { list.push({ id: 3 }); }
catch (err) { console.log(err.constructor.name + ':', err.message); }
try { list[0].id = 99; }
catch (err) { console.log(err.constructor.name + ':', err.message); }

It prints:

frozen -> true
TypeError: Cannot add property 2, object is not extensible
TypeError: Cannot assign to read only property 'id' of object '#<Object>'

Arrays are objects, so the same call freezes them. push fails because the array cannot grow, and element writes fail because the elements are frozen.

Silent failure outside strict mode

Strict mode is what turns a failed write into an error. In sloppy mode the write is ignored and execution continues.

// without strict mode the same writes fail silently
const sloppy = new Function('o', 'o.x = 1; return o.x;');
console.log('silent failure ->', sloppy(Object.freeze({ x: 0 })));

It prints:

silent failure -> 0

The value stayed at 0 and no error was raised. If you are freezing objects for safety, make sure the code doing the writing is strict.

What to remember

  • Object.freeze protects one level only.
  • A recursive freeze needs an isFrozen guard to survive cycles.
  • Use Reflect.ownKeys to cover symbol and non enumerable keys.
  • Failed writes throw in strict mode and are ignored in sloppy mode.

Freezing has a runtime cost on large trees, so it earns its place in development builds and shared constants more than in hot paths.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

Leave a Reply