JAVASCRIPT / OBJECTS AND PROPERTY MECHANICS
Deep copies and structuredClone
Clone whole object graphs with structuredClone, and predict which values it copies, rewrites, or refuses outright.
What you will learn
- Copy a nested object graph in one call instead of only its top level
- Know that Date, Map, Set, RegExp and BigInt survive a clone while functions throw
- Explain why a cloned class instance keeps its fields but loses its methods
- Pick structuredClone over JSON round-tripping when cycles or undefined matter
Understanding Deep copies and structuredClone
A spread copy duplicates a property list: each own enumerable key is read from the source and written into a fresh object. If a value is a number or a string, the copy is genuinely independent afterwards; if the value is an object, what gets duplicated is the reference, so both objects still point at the same nested array or Map. A deep copy instead walks the whole reachable graph and builds a parallel graph, so the copy shares no object with the original. structuredClone(value) performs that walk and returns the root of the new graph.
structuredClone is the structured clone algorithm that postMessage and IndexedDB already used, exposed as an ordinary function. That origin explains its rules: the value is written out into a realm-independent data form and then rebuilt from nothing, so anything whose meaning is code rather than data cannot make the trip. A function or a DOM node raises a DOMException named DataCloneError, and a class instance clones successfully but arrives as a plain object because the link to its prototype is not data. Only own enumerable string-keyed properties are carried over, and they are read with a normal get, so an own getter runs once during cloning and its result is stored as a fixed value.
Against the older JSON.parse(JSON.stringify(value)) trick the differences are not cosmetic. The algorithm remembers every object it has already visited, so a cycle such as obj.self = obj is reproduced as a clone pointing at itself, while JSON.stringify throws a TypeError on the same input. Dates stay Dates, Map, Set, RegExp, typed arrays, Error objects and BigInt all survive, and a property whose value is undefined is kept rather than dropped. The trade is that structuredClone refuses what it cannot represent instead of quietly degrading it, so you find out at the clone site rather than three functions later.
Property attributes are outside the payload as well, which matters when the source was locked down: a frozen or non-writable source produces a plain writable clone, and non-enumerable or symbol-keyed properties vanish without an error.
const settings = {
name: 'editor',
updated: new Date('2024-03-01T09:30:00Z'),
limits: { retries: 3, tags: ['draft', 'wip'] },
seen: new Map([['a', 1]])
};
settings.self = settings;
const deep = structuredClone(settings);
const shallow = { ...settings };
shallow.limits.retries = 99;
console.log('shallow edit ->', settings.limits.retries);
deep.limits.retries = 7;
console.log('deep edit ->', settings.limits.retries, deep.limits.retries);
console.log(deep.updated instanceof Date, deep.updated.getTime() === settings.updated.getTime());
console.log(deep.seen instanceof Map, deep.seen.get('a'));
console.log(deep.self === deep, deep.self === settings);A deep copy rebuilds every object in the graph, and structuredClone does it by turning the value into pure data, which is exactly why functions and prototype links cannot come along.
Worked examples
JSON round-trip versus structuredClone
Shows the values JSON serialisation rewrites or discards and how the same input survives a structured clone.
const record = {
when: new Date('2020-06-01T12:00:00Z'),
count: NaN,
missing: undefined,
ids: new Set([1, 2])
};
const viaJson = JSON.parse(JSON.stringify(record));
console.log(typeof viaJson.when, viaJson.count, 'missing' in viaJson, viaJson.ids instanceof Set);
const viaClone = structuredClone(record);
console.log(viaClone.when instanceof Date, Number.isNaN(viaClone.count), 'missing' in viaClone, viaClone.ids.has(2));
try {
JSON.stringify({ total: 9007199254740993n });
} catch (err) {
console.log(err.name);
}
console.log(structuredClone({ total: 9007199254740993n }).total === 9007199254740993n);Example explained
Line 1JSON.stringify calls Date.prototype.toJSON, so when arrives as a string and .getTime() no longer exists.
Line 2NaN has no JSON literal so it becomes null, the undefined-valued property is omitted, and a Set has no own enumerable data to serialise, leaving {}.
Line 3structuredClone keeps all four as they were, including the property whose value is undefined, because undefined is a serialisable value in this algorithm.
Line 4BigInt has no JSON representation and throws a TypeError, while the structured clone algorithm lists BigInt among the primitives it can transfer.
An own getter is flattened into a value
Demonstrates that accessors are evaluated during cloning and the clone stores whatever they returned at that moment.
const source = {
base: 2,
get doubled() { return this.base * 2; },
nested: { list: [1, 2] }
};
const copy = structuredClone(source);
source.base = 50;
console.log(source.doubled, copy.doubled);
console.log(Object.getOwnPropertyDescriptor(copy, 'doubled').get);
console.log(copy.nested === source.nested, copy.nested.list[1]);Example explained
Line 1Cloning reads doubled with a normal get while base is still 2, so 4 is written into the clone.
Line 2source.doubled recomputes to 100 afterwards, but the clone has no formula left to recompute from.
Line 3The descriptor on the clone has no get function at all: the accessor became a plain data property.
Line 4nested is a different object in the clone, which is the whole point of the deep copy, and its array contents came along.
Methods break the clone, prototypes get lost
Shows the DataCloneError raised by a function-valued property and how to restore a class instance afterwards.
class Point {
constructor(x, y) { this.x = x; this.y = y; }
scaled(k) { return new Point(this.x * k, this.y * k); }
}
const state = { at: new Point(3, 4), onSave() {} };
try {
structuredClone(state);
} catch (err) {
console.log(err.name);
}
const { onSave, ...data } = state;
const copy = structuredClone(data);
console.log(copy.at instanceof Point, typeof copy.at.scaled, copy.at.x);
copy.at = Object.assign(new Point(0, 0), copy.at);
console.log(copy.at instanceof Point, copy.at.scaled(2).y, copy.at === state.at);Example explained
Line 1onSave is a function, so the whole call fails with a DOMException named DataCloneError before anything is copied.
Line 2Destructuring the function away leaves a pure-data object that clones without complaint.
Line 3The cloned Point keeps x and y but not its prototype link, so scaled is undefined rather than callable.
Line 4Object.assign copies the cloned fields onto a fresh Point, which reattaches the prototype and is still a separate object from state.at.
Important notes
Getters run during the clone, so a getter that logs, counts, or fetches something lazily will fire once as a side effect of structuredClone.
structuredClone needs a recent runtime (Node 17, Chrome 98, Firefox 94, Safari 15.4); older targets need a hand-written recursive clone that explicitly handles Date, Map, Set and cycles.
Common mistakes
Treating { ...obj } or Object.assign({}, obj) as a deep copy: copy.tags.push('x') also changes obj.tags, and the corrupted state shows up far from the line that copied it.
Using JSON.parse(JSON.stringify(obj)) as a general deep copy: obj.updated returns as a string so copy.updated.getTime() throws 'is not a function', and any Map or Set silently becomes an empty {}.
Calling a method on a cloned class instance: the prototype link is not cloned, so copy.scaled(2) throws 'copy.scaled is not a function' even though copy.x and copy.y hold the right numbers.
Try it yourself
Change, predict, then run
In a browser console build const a = { tags: ['x'], meta: { at: new Date() } }; a.self = a; clone it with structuredClone, push a tag onto the clone, then check that a.tags.length is still 1, clone.self === clone, and clone.meta.at instanceof Date. Now add a.render = () => {} and clone again to see which error name you get.
Open the JavaScript workspaceCheck your understanding
An object holds a Map, a self-reference, and an arrow function. structuredClone throws on it, but removing only the arrow function makes the clone succeed. What best explains that?
- The clone is produced by writing the value out as realm-independent data and reading it back; Maps and cycles have a defined data form, a function's code and captured variables do not.
- Arrow functions have no prototype property, so the algorithm has nothing to rebuild them from.
- Functions are compared by reference, and the algorithm only handles values that are compared by value.
- The Map is copied by reference so it costs nothing, while the function would require a real copy.
Show answer
structuredClone serialises the graph and rebuilds it, keeping a record of objects it has already visited, which is why the cycle and the Map are both fine. No serialised form can describe a function body together with the scope it closes over, so the algorithm reports DataCloneError instead of guessing. Option 2 is tempting but wrong: a plain function declaration does have a prototype property and fails in exactly the same way, and option 4 misreads the result, since the Map is deeply copied rather than shared.