Deep Cloning in JavaScript: Safe Ways to Copy Objects

Posted by

structuredClone, libraries, and DIY recipes—plus the pitfalls with Dates, Maps, Sets, typed arrays, class instances, circular refs, and performance.

structuredClone, libraries, and DIY recipes—plus the pitfalls with Dates, Maps, Sets, typed arrays, class instances, circular refs, and performance.

Introduction

You copied an object, tweaked a nested field… and your UI mysteriously updated in the wrong place. Classic shallow copy trap.

In JavaScript, most copies are shallow — they duplicate top-level properties but keep references to nested objects. That’s fine until you modify a nested value and accidentally mutate the source. Deep cloning avoids this by recursively copying everything you care about.

But “deep clone” isn’t one-size-fits-all. Dates, Maps, Sets, RegExps, typed arrays, functions, class instances, circular references, and property descriptors all complicate the picture. This guide shows safe strategies — from the built-in structuredClone to production-ready library calls and a high-quality custom implementation—plus clear rules for when to pick which.


1) Shallow vs. Deep: What Actually Breaks

const src = { user: { name: "Ali" } };
const copy = { ...src }; // shallow
copy.user.name = "Umar";

console.log(src.user.name); // "Umar" 😬 (same nested object)
  • Shallow clone ({...o}, Object.assign) copies only the first level.
  • Deep clone recursively copies nested references.

Rule: If you’ll mutate nested structures (e.g., reducers, caches, draft edits), you probably need a deep clone — or a persistent/immutable approach.


2) The Gold Standard: structuredClone (Built-in)

Modern JS gives you a built-in deep copy: structuredClone(value).

const src = {
d: new Date(),
r: /abc/gi,
m: new Map([["k", { x: 1 }]]),
s: new Set([1, 2]),
a: new Uint8Array([1, 2, 3]),
};

const copy = structuredClone(src);
console.log(copy.m.get("k") === src.m.get("k")); // false (deep)

What it handles well

  • Primitives, Objects, Arrays
  • Date, RegExp
  • Map, Set
  • ArrayBuffer, DataView, Typed Arrays
  • Blob, File, FileList (in browser contexts)
  • Circular references (yes!)
  • Prototype of plain objects? Result is a plain object with default prototype for object literals; custom prototypes are not preserved.

What it does not clone

  • Functions, DOM nodes, and some host objects are not cloned (functions become undefined).
  • Class instances lose their prototype methods (become plain objects) unless they’re supported by the structured clone algorithm.

When to use: If you’re on modern runtimes (Node 17+, modern browsers) — start here. It’s fast, robust, and handles most tricky types.

Fallback pattern:

export function deepClone(value) {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)); // last-resort fallback (with caveats)
}

Gotcha: The JSON fallback drops Dates/Maps/Sets/undefined/Infinity/BigInt/RegExp/functions and breaks circular references. Use it only for simple data (API payload–like structures).


3) JSON Tricks (And Their Limits)

const clone = JSON.parse(JSON.stringify(obj));

Pros: Simple, ubiquitous, fast for small plain data.

Cons:

  • Drops: undefined, Infinity, NaN, BigInt, functions, symbols.
  • Converts Date → string, Map/Set → {} or [], RegExp → {}.
  • No circular refs (throws).
  • Loses non-enumerable props and property descriptors.
  • Loses prototypes.

Use only when:

  • Your data is “JSON-safe” (plain objects/arrays with serializable primitives).
  • You explicitly want serialization semantics.

4) Library Option: lodash.clonedeep (or rfdc, clone-deep)

If you need broad compatibility and can afford a small dependency, use a battle-tested library:

npm i lodash.clonedeep
import cloneDeep from "lodash.clonedeep";

const copy = cloneDeep(value);
  • Handles arrays/objects, Dates, RegExps, Maps/Sets (depending on library; Lodash 4 doesn’t deep-clone Map/Set; it clones them as plain objects — verify your version or use a lib that supports them).
  • Preserves circular refs.
  • Doesn’t preserve class prototypes (still plain data).

Alternative: rfdc (Really Fast Deep Clone) is tiny and fast, but limited (no prototypes, limited special types). Pick based on your shapes.

Rule of thumb: For application state (Redux, React, services) with mostly plain data, a library is fine. For type-rich structures, prefer structuredClone or a custom clone.


5) Custom Deep Clone (Production-friendly)

When you need precise control (e.g., preserve Dates/RegExps/Maps/Sets/typed arrays and handle circular refs), this pattern works well:

// deepClone.ts
type AnyObj = Record<PropertyKey, any>;

export function deepClone<T>(input: T, seen = new WeakMap<object, any>()): T {
// Primitives & functions
if (input === null || typeof input !== "object") return input;

// Circular refs
if (seen.has(input as object)) return seen.get(input as object);

// Date
if (input instanceof Date) {
return new Date(input.getTime()) as any;
}

// RegExp
if (input instanceof RegExp) {
const flags = input.flags;
const re = new RegExp(input.source, flags);
re.lastIndex = input.lastIndex;
return re as any;
}

// Array
if (Array.isArray(input)) {
const arr: any[] = new Array(input.length);
seen.set(input, arr);
for (let i = 0; i < input.length; i++) {
arr[i] = deepClone(input[i], seen);
}
return arr as any;
}

// Map
if (input instanceof Map) {
const m = new Map();
seen.set(input, m);
for (const [k, v] of input) m.set(deepClone(k, seen), deepClone(v, seen));
return m as any;
}

// Set
if (input instanceof Set) {
const s = new Set();
seen.set(input, s);
for (const v of input) s.add(deepClone(v, seen));
return s as any;
}

// ArrayBuffer / Typed Arrays
if (ArrayBuffer.isView(input)) {
// Handles typed arrays & DataView
// @ts-ignore
return new (input.constructor as any)(input) as any;
}
if (input instanceof ArrayBuffer) {
return input.slice(0) as any;
}

// Plain object vs class instance
const proto = Object.getPrototypeOf(input);
const out: AnyObj = Object.create(proto); // preserves prototype
seen.set(input as object, out);

// Copy own property descriptors (incl. non-enumerables & symbols)
const descriptors = Object.getOwnPropertyDescriptors(input as AnyObj);
for (const key of Reflect.ownKeys(descriptors)) {
const desc = (descriptors as any)[key];
if ("value" in desc) {
desc.value = deepClone(desc.value, seen);
}
Object.defineProperty(out, key, desc);
}
return out as T;
}

Why this version?

  • Circular reference safe via WeakMap.
  • Preserves Dates, RegExps, Maps, Sets, typed arrays, ArrayBuffer.
  • Preserves property descriptors (getters/setters, enumerability).
  • Preserves prototype chain (important for class instances).

Note: If you clone class instances with custom internal state, preserving the prototype is good — but be sure your class remains valid with the cloned data (e.g., invariants, private slots). Some built-ins with internal slots (like URL) shouldn’t be cloned this way.


6) Special Types & Edge Cases (What to Do)

Dates

  • structuredClone
  • Custom clone: new Date(d.getTime())

RegExp

  • structuredClone
  • Preserve source, flags, lastIndex.

Map/Set

  • structuredClone
  • Custom: rebuild and deep clone keys/values; keep iteration order.

Typed Arrays / ArrayBuffer

  • structuredClone
  • Custom: new (ctor)(input) duplicates buffer; ArrayBuffer#slice.

Functions

  • None of the safe deep clones copy functions (and shouldn’t). Preserve references if needed, or treat them as non-cloneable business logic.

Class Instances

  • structuredClone does not preserve custom prototypes (objectified).
  • Custom clone above does preserve prototypes and descriptors — but be careful that your class invariants still hold.

DOM Nodes / React Elements

  • Don’t deep clone them. Use framework APIs (cloneElement in React) or serialize minimal props/state.

Symbols & Non-enumerables

  • structuredClone does not copy non-enumerables or property descriptors.
  • Custom clone above does (via descriptors loop).
  • JSON loses both.

Property Descriptors & Accessors

  • If getters/setters matter (e.g., computed properties), use the descriptor-aware custom clone.

7) Performance & Memory: Pragmatic Advice

  • structuredClone is fast in modern engines and implemented natively—prefer it when available.
  • JSON is fast for small, plain data; drops types — use only for JSON-safe shapes.
  • Libraries: Adequate for most app data; check Map/Set handling in your lib.
  • Custom: Slightly slower than native but gives you exact control; use in libraries or complex domains.

Hot paths: Avoid unnecessary deep clones in render loops and network pipelines. Consider:

  • Persistent/immutable data (Immer, Immutable.js) to avoid wholesale copies.
  • Shallow copies + structural sharing when only a small branch changes.
  • Schema design: Normalize data (by id) to minimize deep changes.

8) Decision Guide (Quick Picks)


9) Real-World Examples You’ll Actually Use

A) Draft Edit Without Side Effects

const draft = structuredClone(userProfile);
draft.preferences.theme = "dark";
// submit draft; original untouched

B) Cloning a Cache Entry with Binary Data

const cached = {
id: 1,
bytes: new Uint8Array([1,2,3]),
meta: new Map([["etag", "abc"]])
};
const copy = structuredClone(cached);
copy.bytes[0] = 99; // safe

C) Deep Clone & Sanitize (Omit private fields)

function sanitizeUser(user) {
const copy = structuredClone(user);
delete copy.password;
delete copy.ssn;
return copy;
}

D) Preserve Class Behavior (Custom)

class Money { constructor(public cents: number) {} add(x:number){ this.cents+=x } }

const wallet = { balance: new Money(500) };
const cloned = deepClone(wallet); // custom clone that preserves prototype
cloned.balance.add(100); // method still works

E) Defensive Clone at API Boundary

// Avoid callers mutating what you return
function getConfig() {
return structuredClone(INTERNAL_CONFIG);
}

10) Testing Your Clone (Don’t Skip This)

When you swap clone strategies, write targeted tests:

import { expect, test } from "vitest";
import { deepClone } from "./deepClone";

test("handles maps, sets, dates, regexp, cycles", () => {
const obj: any = {
d: new Date(),
r: /a/g,
m: new Map([["k", { v: 1 }]]),
s: new Set([1,2,3]),
arr: [1, { z: 3 }],
};

obj.self = obj; // cycle

const c = deepClone(obj);
expect(c).not.toBe(obj);
expect(c.d.getTime()).toBe(obj.d.getTime());
expect(c.r.source).toBe(obj.r.source);
expect(c.r.flags).toBe(obj.r.flags);
expect(c.m.get("k")).not.toBe(obj.m.get("k"));
expect(c.self).toBe(c);
});

11) TypeScript Tips for Safer Clones

A) Express your intent with types

function cloneJsonSafe<T extends Record<string, unknown>>(x: T): T {
return JSON.parse(JSON.stringify(x));
}

Be honest: cloneJsonSafe tells future you that types like Date/Map/Set won’t survive.

B) Narrow data before cloning

type PublicUser = { id: string; name: string; email: string };
function toPublic(u: any): PublicUser {
const { id, name, email } = u;
return { id, name, email };
}
const safe = structuredClone(toPublic(user));

C) Overloads for convenience

You can overload helpers to accept “maybe undefined” and return the same shape.


12) Common Mistakes (Read Twice)

  • Using spread for deep copy. {...obj} is shallow.
  • JSON cloning for type-rich data. You’ll silently lose Dates/Maps/Sets/functions.
  • Cloning big graphs per render. Deep clones are expensive; prefer structural sharing or Immer.
  • Forgetting circular refs. JSON blows up; custom clone must use WeakMap.
  • Losing class behavior. structuredClone yields plain objects for many custom instances—use custom clone if methods matter.
  • Ignoring descriptors. If you rely on getters/setters, clone with descriptors.

Conclusion

Deep cloning is easy to get wrong and costly to fix. The safest modern default is structuredClone—native, fast, and handles cycles and complex types. For purely JSON-like shapes, JSON cloning is simple and sufficient. If you need full fidelity (prototypes, descriptors) or strict control, a descriptor-aware custom clone is your best friend. Libraries like lodash.clonedeep or rfdc are solid middle grounds for app state.

Key takeaways:

  • Choose the right tool for your data shape.
  • Know what each method preserves (Dates, Maps/Sets, prototypes).
  • Avoid deep clones in hot paths — consider immutable patterns.
  • Test the tricky stuff: cycles, typed arrays, getters/setters, class instances.

Pro Tip: Wrap your preferred strategy in a tiny util (clone.ts) that picks structuredClone when available, falls back sensibly, and exposes a “strict” version that preserves prototypes for advanced cases.


Call to Action (CTA)

What’s the most surprising deep-clone bug you’ve hit — lost Dates, broken class methods, circular refs?
Drop a snippet in the comments. If this helped, bookmark it and share with your team — future you will thank you.


Appendix: Copy-Paste Utilities

// 1) Merge (guards null/undefined)
export function mergeSafe<T>(...parts: Array<ReadonlyArray<T> | null | undefined>): T[] {
const out: T[] = [];
for (const p of parts) if (p && p.length) out.push(...p);
return out;
}

// 2) Unique by key (keep last)
export function mergeByKeyKeepLast<T, K>(lists: T[][], key: (t: T) => K): T[] {
const m = new Map<K, T>();
for (const list of lists) for (const item of list) m.set(key(item), item);
return [...m.values()];
}

// 3) Unique by key (keep first)
export function mergeByKeyKeepFirst<T, K>(lists: T[][], key: (t: T) => K): T[] {
const m = new Map<K, T>();
for (const list of lists) {
for (const item of list) {
const k = key(item);
if (!m.has(k)) m.set(k, item);
}
}
return [...m.values()];
}

// 4) Replace range immutably
export function replaceRange<T>(arr: ReadonlyArray<T>, start: number, end: number, ...items: T[]): T[] {
return [...arr.slice(0, start), ...items, ...arr.slice(end)];
}

// 5) Chunk-safe append
export function pushChunkSafe<T>(target: T[], source: ReadonlyArray<T>, chunkSize = 50_000) {
for (let i = 0; i < source.length; i += chunkSize) {
const end = Math.min(i + chunkSize, source.length);
for (let j = i; j < end; j++) target.push(source[j]);
}
}

Leave a Reply

Your email address will not be published. Required fields are marked *