A circular reference is a chain of properties that leads from an object back to itself. It can be direct, as in obj.self = obj, or pass through other objects, as when a team points to its lead and the lead points back to the team. There's nothing wrong with such data, and you can use it normally. The trouble is code that follows every property into every nested object: it goes round the loop and never reaches the end.
const team = { name: "Frontend" };
const ada = { name: "Ada", team };
team.lead = ada; // team -> ada -> team -> ...
ada.team.lead.team.lead.name; // "Ada": you can go round as often as you like
JSON.stringify(team); // throws a TypeError
Here's a walk that visits every nested object, starting from team:
| Step | The walk is at | It follows | Which leads to |
|---|---|---|---|
| 1 | team | team.lead | ada |
| 2 | ada | ada.team | team, where it started |
| 3 | team | team.lead | ada again, and so on forever |
JSON.stringify detects the loop and throws a TypeError, since the text would never end. A function you write that copies, compares, or freezes nested objects, calling itself for each one, doesn't stop on its own: it keeps calling itself until the call stack runs out and the engine throws. So code that walks nested objects has to notice when it comes back round to an object it has already reached.
Reaching an object twice doesn't always mean a loop, though. A shared reference is one object reached by two different paths, with no way back to where you started:
const office = { city: "London" };
const staff = { ada: { office }, grace: { office } };
staff.ada.office === staff.grace.office; // true: one object, two paths
JSON.stringify(staff);
// '{"ada":{"office":{"city":"London"}},"grace":{"office":{"city":"London"}}}'
const copy = structuredClone(staff); // the built-in deep copy
copy.ada.office === copy.grace.office; // true: one new office, still shared
copy.ada.office === office; // false
JSON.stringify writes a shared object out once per path, so parsing the text back gives two separate offices. structuredClone keeps the sharing, and it keeps cycles too: copying team gives a new team whose lead points back to the new team.
Cycles are common in real data. A DOM element's parentNode has the element among its children, tree nodes often link to their parent, a doubly linked list points both ways, and globalThis.globalThis is globalThis.
Object.freeze is shallow: it freezes an object's own properties, but the objects stored in them can still change. Write deepFreeze(object), which freezes object and every object, array, and function reachable through its properties, and returns object.
Reach every own property, including symbol keys and non-enumerable ones. Don't call getters: freeze only what data properties hold. Handle cycles, and objects whose top level was frozen already (their children may not be). Primitives come back unchanged.
const config = deepFreeze({ api: { url: "/v1" }, flags: ["beta"] });
Object.isFrozen(config.api); // true
Object.isFrozen(config.flags); // true
config.api.url = "/v2"; // ignored, or a TypeError in strict mode
const o = { a: { b: { c: 1 } } };
expect(deepFreeze(o)).toBe(o);
expect(Object.isFrozen(o)).toBe(true);
expect(Object.isFrozen(o.a)).toBe(true);
expect(Object.isFrozen(o.a.b)).toBe(true);
const o = deepFreeze({ list: [{ x: 1 }, [2]] });
expect(Object.isFrozen(o.list)).toBe(true);
expect(Object.isFrozen(o.list[0])).toBe(true);
expect(Object.isFrozen(o.list[1])).toBe(true);
const o = deepFreeze({ a: { b: 1 } });
expect(() => {
"use strict";
o.a.b = 2;
}).toThrow(TypeError);
expect(o.a.b).toBe(1);
const o = { name: "root", child: {} };
o.self = o;
o.child.parent = o;
expect(deepFreeze(o)).toBe(o);
expect(Object.isFrozen(o)).toBe(true);
expect(Object.isFrozen(o.child)).toBe(true);
const o = Object.freeze({ inner: { x: 1 } });
deepFreeze(o);
expect(Object.isFrozen(o.inner)).toBe(true);
const key = Symbol("key");
const o = { [key]: { x: 1 } };
Object.defineProperty(o, "hidden", { value: { y: 2 }, enumerable: false });
deepFreeze(o);
expect(Object.isFrozen(o[key])).toBe(true);
expect(Object.isFrozen(o.hidden)).toBe(true);
const fn = () => {};
fn.options = { retries: 3 };
deepFreeze({ fn });
expect(Object.isFrozen(fn)).toBe(true);
expect(Object.isFrozen(fn.options)).toBe(true);
const getter = spy(() => ({ fresh: true }));
const o = {};
Object.defineProperty(o, "lazy", { get: getter, enumerable: true });
deepFreeze(o);
expect(getter).not.toHaveBeenCalled();
expect(Object.isFrozen(o)).toBe(true);
expect(deepFreeze(42)).toBe(42);
expect(deepFreeze("text")).toBe("text");
expect(deepFreeze(null)).toBeNull();
expect(deepFreeze(undefined)).toBeUndefined();
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Deep freeze. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.