Deep Clone with structuredClone
Deep cloning is no longer a `JSON.parse(JSON.stringify(x))` hack. Modern runtimes ship `structuredClone`, which handles cycles, Maps, Sets, typed arrays, and Date out of the box. This snippet shows the canonical built-in usage, the JSON fallback for legacy code paths and its real limits, and a hand-rolled recursive clone for when you need to skip specific keys or types. Pick the right tool and stop carrying lodash for one helper.
326 views
6
const original = {
id: 1,
when: new Date('2024-01-01'),
bytes: new Uint8Array([1, 2, 3]),
nested: { tags: new Set(['a', 'b']) },
};
const copy = structuredClone(original);
copy.nested.tags.add('c');
console.log(original.nested.tags); // Set(2) { 'a', 'b' }
console.log(copy.nested.tags); // Set(3) { 'a', 'b', 'c' }
console.log(copy.when instanceof Date); // true
console.log(copy.bytes instanceof Uint8Array); // truestructuredClone ships in Node 17+ and every evergreen browser. Unlike a JSON round-trip, it preserves Date, RegExp, Map, Set, ArrayBuffer, typed arrays, and even circular references via the HTML structured-clone algorithm. The clone is fully detached, so mutating the copy never leaks back into the source. The only common types it cannot copy are functions, DOM nodes, and class instances with non-cloneable internal slots (it throws DataCloneError for those). Use this version by default for plain data.
function jsonClone(value) {
return JSON.parse(JSON.stringify(value));
}
const input = {
when: new Date('2024-01-01'),
bytes: new Uint8Array([1, 2, 3]),
note: undefined,
fn: () => 1,
big: 10n,
};
try {
console.log(jsonClone(input));
// { when: '2024-01-01T00:00:00.000Z', bytes: { '0': 1, '1': 2, '2': 3 } }
} catch (e) {
console.log('threw:', e.message);
}
// JSON.stringify throws on BigInt, so the bigint case fails outright.The JSON round-trip is fast and dependency-free, but it silently downgrades types: Date becomes an ISO string, Map/Set become {}, typed arrays become plain numeric-keyed objects, undefined and functions disappear, and BigInt throws. It also cannot represent cycles. It still has a niche for tiny configuration objects in legacy environments, or when you explicitly want a JSON-safe snapshot. Reach for structuredClone first; only drop to this when you must support a runtime older than Node 17.
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
if (Array.isArray(value)) {
const arr = [];
seen.set(value, arr);
for (const item of value) arr.push(deepClone(item, seen));
return arr;
}
const out = Object.create(Object.getPrototypeOf(value));
seen.set(value, out);
for (const key of Reflect.ownKeys(value)) {
out[key] = deepClone(value[key], seen);
}
return out;
}
const node = { name: 'A', children: [] };
node.self = node; // cycle
const cloned = deepClone(node);
console.log(cloned.self === cloned); // true (cycle preserved)
console.log(cloned.self === node); // false (truly detached)Sometimes you need cloning behaviour that structuredClone cannot give you: skipping certain keys, copying class instances with custom prototypes, or running in a sandbox without the global. The recursive version handles cycles by memoising every node in a WeakMap keyed by the source reference. Reflect.ownKeys covers both string and symbol keys, and preserving Object.getPrototypeOf keeps class instances usable. The trade-off is that you now own the type table (Date, RegExp, and any custom types you care about); each new type adds another branch.
