10LOC
Node.jsintermediate

structuredClone for a true deep copy, no JSON hacks

Published September 25, 2026

type Session = { id: string; startedAt: Date; roles: Set<string>; meta: Map<string, number> };

export const cloneSession = (session: Session): Session => structuredClone(session);

const original: Session = {
  id: "abc123",
  startedAt: new Date(),
  roles: new Set(["admin", "editor"]),
  meta: new Map([["retries", 0]]),
};

const copy = cloneSession(original);
copy.roles.add("viewer");
console.log(original.roles.size, copy.roles.size); // 2 3 — independent copies

What

structuredClone(value) is a global function, available in Node with no import, that produces a real deep copy of a value — including types JSON.stringify can't represent at all.

Why it matters

JSON.parse(JSON.stringify(value)) has been the go-to deep-copy hack for years, and it mostly works — right up until the value contains something JSON can't express. A Date round-trips as a string, not a Date. A Map or Set round-trips as {} — silently, with no error, just quietly wrong data. undefined values vanish. And if the object has a circular reference, JSON.stringify throws outright.

structuredClone implements the same structured-clone algorithm browsers use for postMessage and IndexedDB. It knows about Date, Map, Set, RegExp, ArrayBuffer/typed arrays, and circular references natively, because it's not going through a text format at all — it's a direct graph clone.

How it works

  • Session intentionally mixes a Date, a Set, and a Map — exactly the types that break the JSON round-trip — to make the difference concrete rather than theoretical.
  • structuredClone(session) walks the object graph and produces independent copies of every nested structure, not just the top-level object.
  • Mutating copy.roles (copy.roles.add("viewer")) after cloning proves the two Sets are genuinely separate instances — original.roles.size stays at 2 while copy.roles.size becomes 3.

Gotchas

  • structuredClone can't clone functions, DOM nodes, or anything with a prototype chain it doesn't recognize (class instances lose their prototype — you get a plain object back with the same own properties, not an instance of the original class).
  • It's a genuine deep clone, so it's not free — for very large or deeply nested objects, it costs more than a shallow { ...obj } spread. Don't reach for it when a shallow copy would do.
  • Available as a global since Node 17 — no import needed, but double-check your target runtime if you're supporting anything older.

Related

  • Object.assign({}, obj) / spread ({ ...obj }) — shallow copy only; nested objects/arrays are still shared references.
  • Array.prototype.slice() — the array-specific shallow-copy equivalent, same caveat applies to nested items.