Object.freeze() is described as shallow. What does that mean concretely, and how would you implement a deep freeze that handles an object graph containing cycles?
answer
- only own property slots are locked
- the referenced object stays open
- recursion over own keys
- a self-reference loops forever
- descriptors avoid firing getters
basics
~20 sShallow means only the object's own properties are locked; objects those properties point at stay fully mutable. A deep freeze recurses over own keys and freezes each object value, tracking already-visited objects so a cycle does not recurse forever.
solid answer
~40 s`Object.freeze(obj)` sets `writable: false` and `configurable: false` on `obj`'s **own** properties and marks it non-extensible. It never looks at the values. So in `Object.freeze({ db: { host: 'x' } })` the `db` reference cannot be replaced, but `cfg.db.host = 'y'` succeeds — the nested object was never touched. A deep freeze walks the graph yourself: for each own key, if the value is an object or function, recurse, then freeze the current object. Cycles need a guard — either a `WeakSet` of visited objects or freezing the object *before* recursing and skipping anything `Object.isFrozen` already reports. Read values through `Object.getOwnPropertyDescriptor` rather than `obj[key]` so you do not invoke getters, and use `Reflect.ownKeys` if symbol-keyed properties matter.
code
javascript · 18 linesfunction deepFreeze(value, seen = new WeakSet()) {
if (value === null) return value;
const t = typeof value;
if (t !== 'object' && t !== 'function') return value;
if (seen.has(value)) return value;
seen.add(value);
for (const key of Reflect.ownKeys(value)) {
const desc = Object.getOwnPropertyDescriptor(value, key);
if (desc && 'value' in desc) deepFreeze(desc.value, seen);
}
return Object.freeze(value);
}
const node = { name: 'root', child: { name: 'leaf' } };
node.self = node; // cycle
deepFreeze(node);
node.child.name = 'changed';
console.log(Object.isFrozen(node.child), node.child.name); // true leafgo deeper
Be ready to state that freeze only locks the top level and to show the two-line proof where a nested property still changes. Know that a deep freeze means recursing yourself.
Write the recursive helper on the spot and justify each part: the cycle guard, Reflect.ownKeys for symbols and non-enumerables, and descriptor reads so getters do not fire.
Say where deep freezing belongs and where it costs too much — module-load config yes, per-request payloads no — and name the state it cannot reach at all: Map/Set/Date internals, private fields, closures.
Decide the codebase's immutability strategy: runtime deep-freeze as a dev-only bug detector, a copy-on-write convention, or persistent data structures, and be explicit about which guarantees each one actually delivers in production.
## What "shallow" actually means The freeze operation is defined over the target's own property *slots*, not over the values in them. It marks the object non-extensible and turns off `writable` and `configurable` on each own property. A property holding an object reference becomes a locked slot containing an unlocked object: ```js const cfg = Object.freeze({ db: { host: 'localhost', port: 5432 } }); cfg.db = {}; // fails — the slot is read-only cfg.db.host = 'evil'; // succeeds — the inner object was never frozen ``` That asymmetry is the whole trap. A reviewer sees `Object.freeze` at the top of a config module and assumes the tree is safe; only the first level is. The same applies to arrays of objects (`Object.freeze(list)` fixes the elements' identities, not their contents) and to prototypes: freezing an instance does not freeze `Object.getPrototypeOf(instance)`, so a shared method on the prototype can still be replaced. ## A correct deep freeze ```js function deepFreeze(value, seen = new WeakSet()) { if (value === null) return value; const t = typeof value; if (t !== 'object' && t !== 'function') return value; // primitives if (seen.has(value)) return value; // cycle guard seen.add(value); for (const key of Reflect.ownKeys(value)) { const desc = Object.getOwnPropertyDescriptor(value, key); if (desc && 'value' in desc) deepFreeze(desc.value, seen); } return Object.freeze(value); } ``` Four deliberate choices here: 1. **Cycle guard.** `a.self = a` would otherwise recurse until the stack overflows. A `WeakSet` records objects already entered; adding *before* recursing is what makes a self-reference terminate. An alternative guard is to call `Object.freeze(value)` first and then skip anything already `Object.isFrozen`, but that conflates "I visited it" with "someone froze it earlier", so a pre-frozen object with mutable children gets skipped. 2. **Descriptor reads, not `value[key]`.** Reading a property with `[]` triggers a getter, which may have side effects, may be expensive, or may return a fresh object each call that you would then pointlessly freeze. `'value' in desc` restricts the recursion to data properties and leaves accessors alone. 3. **`Reflect.ownKeys`** returns string *and* symbol keys, including non-enumerable ones — `Object.keys` would silently skip both, leaving parts of the graph mutable. 4. **Functions are objects too.** A function stored on the object has its own properties and can be extended; including `typeof value === 'function'` freezes those as well. This is optional and sometimes undesirable, since some libraries attach state to functions. ## What deep freeze still cannot reach Freezing is a property-level operation, so any state that does not live in properties survives: - **`Map`, `Set`, `WeakMap`, `Date`, typed arrays** keep their contents in internal slots. `Object.freeze(new Map()).set('k', 1)` works fine, and `date.setFullYear(2000)` still mutates a frozen `Date`. - **`#private` class fields** are not properties either; a method on a frozen instance can still increment `#count`. - **Closure state** captured by methods is untouched — that is the mechanism the module pattern relies on. - **Accessor properties** keep their setters, which can mutate anything they close over. If immutability of those matters, you need a different representation — plain objects and arrays rather than `Map`/`Date`, or a library with persistent data structures. ## Cost and alternatives Deep freezing walks the whole graph once, which is fine at module load for a config tree and wasteful in a hot path for large per-request data. Common patterns: - Deep-freeze **only in development** and skip it in production builds, treating it as a bug detector rather than a runtime guarantee. - Freeze at the boundary where an object becomes shared, not everywhere. - Prefer producing new objects (`{ ...old, k: v }`) so nothing needs freezing, and use freeze to catch violations of that discipline. A related note: `structuredClone` and `JSON.parse(JSON.stringify(x))` both produce fresh, *unfrozen* objects, so cloning a frozen tree gives you a mutable copy — occasionally what you want, occasionally a surprise. ## In an interview The expected answer is: name the shallowness, show the two-line demonstration, then write the recursive helper and, unprompted, mention the cycle guard and the getter problem. Those last two are what separates a memorized snippet from someone who has actually run it on real data.
- Why read each value through getOwnPropertyDescriptor instead of just obj[key]?Because `obj[key]` invokes a getter if the property is an accessor. That can have side effects, be expensive, or return a newly allocated object on every call — which you would then freeze pointlessly while the real state stays mutable. Reading the descriptor lets you recurse only into data properties (`'value' in desc`) and leave accessors alone, which is the honest thing to do since freeze cannot lock an accessor's behaviour anyway.
- You deep-freeze an object containing a Map. What happens?The `Map` object itself becomes non-extensible with its own properties locked, but its entries live in an internal slot rather than in properties, so `map.set('k', 1)` still works and `map.size` still changes. `Date`, `Set` and typed arrays behave the same way. If the data must genuinely be immutable, represent it as a plain object or array, or clone-on-write instead of relying on freeze.
- Is using Object.isFrozen as the cycle guard equivalent to a WeakSet?Not quite. Freezing before recursing and skipping anything already frozen does terminate cycles, but it also skips subtrees that were frozen earlier by someone else — and a shallow-frozen object can still have fully mutable children, which you would then leave unprotected. A `WeakSet` tracks 'already visited by this traversal', which is the property you actually need, and it keeps no strong references.
saying these in an interview costs you the question
- Assuming Object.freeze recurses into nested objects
- Writing a deep freeze with no cycle protection
- Using Object.keys and missing symbols and non-enumerables
- Thinking a frozen Map or Date cannot be mutated
- Believing a clone of a frozen object is frozen too