Deep Clone Implementation
Interview deep clone: JSON is a lossy subset, structuredClone is the host, and recursion plus a WeakMap is how you survive cycles.
- javascript
- clone
- interview
- objects
JSON.parse(JSON.stringify(user)) looks like a deep clone until a Date becomes a string, a method disappears, undefined keys vanish, a Map turns into {}, and a parent pointer throws TypeError. The next candidate writes a recursive copy and hangs the tab on node.self = node.
There are three tools, and they are not interchangeable. JSON is a lossy subset of JavaScript values. structuredClone is the host algorithm (the same one postMessage uses). Recursion plus a WeakMap is the interview clone: arrays and plain objects, cycles that reuse the copy, maybe Date / Map / Set if you have time.
Key takeaways
- JSON round-trip drops functions and
undefinedkeys, stringifiesDate, emptiesMap/Set, and throws on cycles.structuredClonekeeps cycles,Date,Map,Set, and binary data. Functions and DOM nodes throwDataCloneError.- Interview recursion: copy primitives as-is, branch on
Array.isArray, walk own enumerable keys for objects.- A
WeakMapfrom original to clone handles cycles and shared subtrees (copy.a === copy.bwhena === b).- Preserve array holes with
index in array. AJSONclone would have writtennull.
JSON is a subset, structuredClone is the host, recursion is the interview
Start by naming what you are not building. JSON.stringify is a serializer. Using it as a copier inherits every serializer rule.
| Value | JSON.parse(JSON.stringify(x)) |
structuredClone(x) |
Interview recursion |
|---|---|---|---|
| plain object / array | copy of JSON-able fields | copy | copy |
Date |
ISO string | Date |
new Date(x.getTime()) if you handle it |
Map / Set |
{} |
contents copied | extra credit: iterate and clone entries |
undefined in object |
key omitted | kept | kept if you assign it |
| function | omitted (object) / null (array) |
throws | same reference, or skip; do not pretend you cloned the function |
| cycle | TypeError |
clone that cycles | WeakMap hit returns the in-progress clone |
| class instance | enumerable data, methods gone | data, prototype is not the class | own keys; say whether you keep Object.getPrototypeOf |
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value;
}
if (typeof value === 'function') {
return value;
}
if (seen.has(value)) {
return seen.get(value);
}
if (value instanceof Date) {
const copy = new Date(value.getTime());
seen.set(value, copy);
return copy;
}
if (value instanceof Map) {
const copy = new Map();
seen.set(value, copy);
value.forEach((entryValue, entryKey) => {
copy.set(deepClone(entryKey, seen), deepClone(entryValue, seen));
});
return copy;
}
if (value instanceof Set) {
const copy = new Set();
seen.set(value, copy);
value.forEach((entry) => {
copy.add(deepClone(entry, seen));
});
return copy;
}
if (Array.isArray(value)) {
const copy = [];
seen.set(value, copy);
for (let i = 0; i < value.length; i++) {
if (i in value) {
copy[i] = deepClone(value[i], seen);
}
}
return copy;
}
const copy = Object.create(Object.getPrototypeOf(value));
seen.set(value, copy);
for (const key of Object.keys(value)) {
copy[key] = deepClone(value[key], seen);
}
return copy;
}
const node = { id: 1, when: new Date('2026-11-08T00:00:00.000Z') };
node.self = node;
const copy = deepClone(node);
copy !== node; // true
copy.self === copy; // true
copy.self !== node; // true
copy.when instanceof Date; // true
copy.when.getTime() === node.when.getTime(); // true
copy.when !== node.when; // true
Register the clone in seen before walking children. If you set it after the loop, a cycle recurses forever because the original is never found. That is the same “pin the node first” idea as building a graph.
WeakMap keys must be objects. Primitives never go in seen; they returned on the first line. That is why you do not use a Map of all values: 1 and another 1 are not identity, and you would pin every object forever. WeakMap lets the original graph get collected.
Cycles vs diamonds
Stringify tracks the current path and throws on a back-edge, so a diamond { a: x, b: x } serializes x twice. A clone should share: copy.a === copy.b, and both distinct from x. The WeakMap is “have I already allocated the clone for this original?”, not a stack you pop. Do not seen.delete after the node. Deleting would split a diamond into two copies and still fail a cycle that points at an ancestor if you pop too early.
Arrays vs plain objects
typeof [] === 'object', so branch on Array.isArray. Walk indexes from 0 up to length and copy only when i in value. Holes stay holes and length is preserved. for...of skips holes and push densifies. Same trap as deep equality and map.
Object.keys on a plain object is own enumerable string keys. Symbol keys are extra credit (Object.getOwnPropertySymbols). Non-enumerable fields and getters that throw are extra credit. Inherited enumerable keys are usually not copied; you walk own keys and, if you care, set the prototype with Object.create.
What structuredClone does that you should not re-spec
Call structuredClone in production for supported types. It clones Date, RegExp, Map, Set, ArrayBuffer, Blob, and cycles. It throws on functions, DOM nodes, and most platform objects with identity. It does not clone the class prototype: a User instance comes back as a plain data object. Transfer semantics (transfer option) are out of scope for a whiteboard.
Do not implement clone in terms of JSON “because it is shorter” and then claim Date survived. The deep vs shallow copy page is the conceptual pair. The platform structured clone page is the host pair. This page is the function they ask you to type.
Footguns
- Cloning
thisof a bound function, or “deep cloning” a function at all. There is no useful deep copy of bytecode.structuredClonethrows. Returning the same function is honest. RegExplastIndex: copysourceandflags, then setlastIndexif they care.- Prototype:
Object.create(null)vs{}. If the original had a null prototype,{}addsObject.prototype. - Accessors:
obj.foomay run a getter.Object.getOwnPropertyDescriptorsplusdefinePropertiesis the property-faithful clone;obj[key]is the value snapshot. Say which. const copy = valueis not a clone.{ ...value }is shallow.
Interview answer (out loud)
JSON.parse of JSON.stringify is a lossy subset: dates become strings, functions and undefined keys disappear, maps become empty objects, cycles throw. structuredClone is the host: it keeps cycles, Date, Map, and binary data, and it throws on functions. For the interview I recurse. Primitives return as-is. I put the original in a WeakMap pointing at the new array or object before I walk children, so a cycle returns the in-progress clone and a shared subtree stays shared. Arrays use index-in-object so holes survive. I would not pop the WeakMap the way stringify pops its path stack.
Further reading
Related on this site
- Deep vs shallow copy
- Structured clone
- Deep equality in JavaScript
- WeakMap and WeakSet
- Implement JSON.stringify
- Immutability patterns
Related guides
- Deep Equality in JavaScriptCompare values structurally: primitives, NaN, arrays, plain objects, dates, and the cycle that will infinite-loop a naive recursion.
- Flatten and Unflatten ObjectsTurn nested objects into dot-path keys and back. Arrays vs objects, numeric keys, and why round-tripping `a.b[0]` is a product decision.
- Get and Set Nested Object PathsImplement get(obj, path, fallback) and set(obj, path, value) for dot paths. Missing segments, non-object middles, and mutating vs cloning.
- Object.assign PolyfillCopy enumerable own properties onto a target. Getters that run, symbols, null/undefined sources skipped, and the mutation-in-place return value.
- Object.entries and fromEntriesRound-trip own enumerable string keys with Object.entries and Object.fromEntries. Not a Map, not symbols, and later duplicate keys win.