Why Spread’s “Safe Copy” Reputation Is Dangerously Incomplete

JavaScript’s spread (…) and rest (…) look like “copy” tools, but they perform shallow operations whose safety depends on context: arrays vs objects vs calls, the data’s depth, and whether you mean clone, merge, omit, transform, or expand arguments. This guide is a compact diagnostic: when spread/rest are appropriate, where they fail, and which alternatives to use.

Further reference:

Pick your goal: what does “safe copy” mean here?

GoalToolWorks forLimitations
New top-level container[…arr], {…obj}Shallow copyNested objects/arrays still shared
Merge plain objects{…a, …b}Shallow merge (right-most wins)Nested branches replaced, not deep-merged
Gather args / omit fieldsfunction(…args) / { a, …rest }New array/object createdStill shallow for nested values
Deep independent copystructuredClone(value)Deep clone for many built-in types; supports cyclesDoesn’t clone functions or preserve custom prototypes

Shallow copy: when spread/rest is the right, simple choice

If your data is flat (primitives or values you won’t mutate), spread is concise and correct.

Array: new container, same elements

const a = [1, 2, 3];
const copy = [...a];
copy.push(4);
console.log(a);    // [1, 2, 3]
console.log(copy); // [1, 2, 3, 4]

If elements are objects, only references are copied:

const a = [{ id: 1 }, { id: 2 }];
const b = [...a];      // new array, same object refs
b[0].id = 99;
console.log(a[0].id);  // 99 — shared object

Object: top-level props copied

const user = { name: 'Ari', age: 20 };
const userCopy = { ...user };
userCopy.age = 21;
console.log(user.age); // 20

But nested properties remain shared:

const user = { name: 'Ari', contact: { email: 'a@example.com' } };
const userCopy = { ...user };
userCopy.contact.email = 'b@example.com';
console.log(user.contact.email); // 'b@example.com' — shared

Targeted immutable updates: copy only the levels you change

When you will mutate nested data, clone each level you will touch rather than deep-cloning everything. This keeps updates predictable and performant.

const state = {
  user: { name: 'Ari', contact: { email: 'a@example.com' } },
  tags: ['js', 'web'],
};

const next = {
  ...state,                        // top-level copy
  user: {
    ...state.user,                 // copy user
    contact: {
      ...state.user.contact,       // copy contact
      email: 'b@example.com',      // change
    },
  },
  tags: [...state.tags, 'accessibility'], // copy array + append
};

console.log(state.user.contact.email); // 'a@example.com'
console.log(next.user.contact.email);  // 'b@example.com'

For arrays of objects, create new element objects only if you will change them:

const products = [{ id:1, price:10 }, { id:2, price:20 }];
const updated = products.map(p => p.id === 2 ? { ...p, price: 25 } : p);
// updated[1] is a new object; products[1] unchanged

If your code involves async updates, coordinate changes to avoid races — see JavaScript Promises & Async/Await Explained.

Merging settings and descriptor caveats

Object spread is a shallow merge; the right-most source wins for colliding keys.

const defaults = { theme: { dark:false, contrast:'normal' }, pageSize: 10 };
const user = { theme: { dark:true } };
const merged = { ...defaults, ...user };
console.log(merged.pageSize);       // 10
console.log(merged.theme);          // { dark: true } — contrast lost

To merge nested branches explicitly:

const deepMerged = {
  ...defaults,
  ...user,
  theme: { ...defaults.theme, ...user.theme },
};

Object spread reads enumerable own properties (invoking getters) and writes plain data properties: accessors and prototypes are not preserved. For class/prototype semantics, see JavaScript Classes Explained.

Rest and spread in calls: gather vs expand

Rest (…) gathers values into a new array/object in parameter lists or destructuring; spread expands iterables into literals or argument lists. Neither deep-clones nested values.

function sum(...nums) { return nums.reduce((s,n) => s + n, 0); }
sum(1,2,3); // 6

const user = { id: 1, name: 'Ari', password: 'secret' };
const { password, ...publicUser } = user; // publicUser is a shallow copy

Spread in calls expands an iterable into positional arguments (it does not clone):

const nums = [3,7,2];
const max = Math.max(...nums); // 7

When you need an independent deep copy

Use a deep-clone tool when shallow copies are insufficient. In modern environments, structuredClone is the recommended built-in.

const original = {
  user: { name: 'Ari' },
  tags: ['js','web'],
  created: new Date(),
  map: new Map([['k','v']]),
};

const clone = structuredClone(original);
clone.user.name = 'Bea';
clone.tags.push('ui');

console.log(original.user.name);   // 'Ari'
console.log(original.tags.length); // 2

structuredClone supports many built-ins and circular references, but it does not clone functions and it won’t resurrect custom prototypes. Check MDN for environment support: structuredClone documentation.

JSON.parse(JSON.stringify(…)) is an older shortcut that only works for JSON-compatible, acyclic data and will drop or transform functions, undefined, Dates, NaN, Infinity, and symbols.

If you handle API payloads, confirm the shape before cloning/mutating — see Introduction to APIs for Beginners.

Arrays of objects: decide whether elements must be new too

const a = [{ id:1, meta:{active:true} }, { id:2, meta:{active:false} }];
const b = [...a, { id:3 }]; // new array, same element objects

// To isolate element changes:
const c = a.map(item => ({ ...item }));      // shallow element copies (meta still shared)
const d = a.map(item => structuredClone(item)); // deep element copies (independent)

Prefer the least cloning required for correctness to avoid unnecessary work.

Prototype, symbols, and descriptor loss

  • Prototype: spread produces a plain object with own enumerable properties; methods and prototype chain are not copied.
  • Accessors: getters run during copy; resulting property is a plain value, not an accessor.
  • Enumerability: non-enumerable properties are skipped; enumerable symbol keys are copied.
class Person { constructor(name){ this.name = name } greet(){ return `Hi, ${this.name}` } }
const p = new Person('Ari');
const data = { ...p }; // { name: 'Ari' } — greet() gone

Diagnostic checklist

  1. Define intent:
    • Top-level container only? Or independent nested values?
    • Merge vs clone vs argument expansion?
  2. Check depth: are there nested structures you will mutate?
  3. Simulate the mutation on the copy: if the original changes too, you share references.
  4. Confirm semantics: merges are shallow; getters become values; prototypes aren’t preserved.
  5. Choose the tool:
    • Use spread/rest for shallow copies, merges, and gathering/expanding values.
    • Use structuredClone for deep cloning of supported types.
    • Use JSON methods only for JSON-shaped, acyclic data.

Concrete gotchas

Omitting a field doesn’t scrub nested secrets

const user = { id:1, name:'Ari', tokens:{ access:'abc', refresh:'xyz' } };
const { tokens, ...safeUser } = user; // safeUser.tokens is gone, but tokens object still exists
// To truly sanitize:
const sanitized = { ...safeUser, tokens: { access: '[redacted]', refresh: '[redacted]' } };

Merging replaces nested branches

const a = { cfg:{ retries:3, mode:'fast' } };
const b = { cfg:{ retries:5 } };
const merged = { ...a, ...b }; // merged.cfg is { retries:5 } — mode lost

Getter becomes a plain value

const metrics = { get now(){ return Date.now(); } };
const snapshot = { ...metrics };
console.log(typeof snapshot.now); // 'number' — no getter preserved

Alternatives to copying everything

  • Immutable updates: copy only changed branches (as shown above).
  • Normalize data: store lists of IDs and a map of objects by ID so updates touch fewer nodes.
  • Derive views: use map/filter to create derived arrays rather than mutating originals.

For common pitfalls and state organization patterns, see Common JavaScript Mistakes Beginners Make and JavaScript Classes Explained.

Three Questions to Ask Before Copying

Before writing [...value] or {...value}, identify what must be independent. Is only the outer container changing, or will nested objects also be edited? Is the operation a merge with predictable property precedence, a conversion between iterable values, or a true deep clone? These questions prevent the common mistake of choosing syntax first and discovering its semantics later.

Also check the input type. An array can be spread into an array or passed as arguments because it is iterable. A plain object can be spread into an object literal, but it is not automatically iterable for an array literal. If the value may be null, a class instance, a getter-backed object, or a large argument list, test that specific case rather than relying on the visual similarity of the three dots.

Finally, prefer the smallest operation that matches the contract. A shallow copy is often the clearest choice for a one-level immutable update. A targeted nested update can preserve sharing intentionally. A deep clone is appropriate only when the supported data types and independence requirement justify it. Copying everything by default can hide the real data model and make later changes harder to reason about.

A Small Test Matrix Beats a Slogan

When the behavior is unclear, write a small test that checks identity as well as values. Compare the outer container with the nested value: copy is original should usually be false, while copy[0] is original[0] may be true after a shallow array copy. For object updates, also check which property wins when two sources contain the same key. These tiny checks turn an assumption about the three dots into an observable contract and are easier to maintain than a broad promise that the result is “safe.”

FAQ

Is spread the same as rest?

No. Spread expands values in literals or call expressions; rest gathers values into a new array/object in parameters or destructuring. The syntax is identical-looking but opposite in direction.

Why did changing a nested value in my “copy” change the original?

Because spread/rest perform shallow copies: nested objects/arrays are referenced, not duplicated. Copy each level you will mutate or use a deep clone like structuredClone.

When should I prefer structuredClone over JSON.parse(JSON.stringify())?

Prefer structuredClone when you need a correct deep copy for Dates, Map/Set, typed arrays, ArrayBuffer, and circular structures. Use JSON for simple, JSON-compatible payloads only.

Will spread copy class instances and methods?

No. Spreading an instance copies its own enumerable properties into a plain object; methods on the prototype are not carried over. If you need instance-level copying, implement clone logic on the class or keep plain data structures for copying.

Is spread faster than a loop or structured clone?

Performance depends on data size and environment. Choose semantics first. If performance matters, benchmark realistic workloads in your runtime.

Leave a Comment

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

Scroll to Top