Spread, Merge and Cloning

Mental model: A spread copy is one level deep. Everything nested is still shared with the original.

Level: beginner · about 12 minutes

const base = { theme: 'dark', lang: 'en' };
const copy = { ...base, lang: 'fr' };   // later keys win

console.log(copy);          // { theme: 'dark', lang: 'fr' }
console.log(base.lang);     // 'en', untouched
console.log(copy === base); // false

Spread builds a new object with the same own enumerable properties.

Spread reads the own enumerable properties of the source and writes them into a fresh object. Order matters: whatever comes last wins, which is what makes { ...defaults, ...overrides } the standard merge.

Object.assign does the same thing, into an existing target

Spread: new object

const merged = { ...a, ...b };
// a and b are untouched

Object.assign: mutates the target

Object.assign(a, b);
// a has changed
const safe = Object.assign({}, a, b);
// unless the target is fresh

Reach for spread by default. Object.assign earns its place when you must copy onto an object that already exists, or when you want the return value to be that same reference.

Shallow means one level. Prove it.

const original = { name: 'Ada', address: { city: 'London' } };
const shallow = { ...original };

shallow.name = 'Grace';              // a new top-level property, fine
shallow.address.city = 'Paris';      // the SAME nested object

console.log(original.name);          // 'Ada'
console.log(original.address.city);  // 'Paris'  <-- the leak
console.log(original.address === shallow.address); // true

Both objects point at the same nested address.

Deep copies: structuredClone

const state = { user: { name: 'Ada' }, seen: new Set([1, 2]), when: new Date(0) };
const deep = structuredClone(state);

deep.user.name = 'Grace';
console.log(state.user.name);          // 'Ada', properly independent
console.log(deep.seen instanceof Set); // true
console.log(deep.when instanceof Date);// true
try {
  structuredClone({ run() {} });
} catch (err) {
  console.log(err.name); // 'DataCloneError'
}

// It does handle cycles, which JSON cannot:
const cyclic = { id: 1 };
cyclic.self = cyclic;
console.log(structuredClone(cyclic).self.id); // 1

structuredClone refuses functions, symbols and DOM nodes.

The JSON round trip, and what it destroys

const messy = { n: undefined, f() {}, when: new Date(0), inf: Infinity, set: new Set([1]) };
const round = JSON.parse(JSON.stringify(messy));

console.log(round);
// { when: '1970-01-01T00:00:00.000Z', inf: null, set: {} }
// undefined and functions vanished, the Date became a string, Infinity became null
TechniqueDepthSurvivesUse when
{ ...obj }one levelplain values and referencesthe default copy
Object.assign(t, s)one levelsame as spreadyou need to write into an existing target
structuredClone(obj)deepDate, Map, Set, RegExp, cycles, typed arraysreal data you must fully detach
JSON.parse(JSON.stringify(o))deepstrings, numbers, booleans, arrays, plain objectsthe data is already JSON safe
a hand written deepMergedeepwhatever you code formerging config trees
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

const bad = {};
for (const key of Object.keys(payload)) bad[key] = payload[key]; // assigns __proto__
console.log(Object.keys(payload));      // ['__proto__'] - an own key, not a link
console.log({}.isAdmin);                // undefined here, because assignment used the setter
console.log('never trust a key name you did not write');

The payload arrives as ordinary JSON, which is what makes it easy to miss.

Immutable update patterns

const state = { user: { name: 'Ada', prefs: { theme: 'dark' } }, items: [1, 2] };

const next = {
  ...state,
  user: { ...state.user, prefs: { ...state.user.prefs, theme: 'light' } },
  items: [...state.items, 3],
};

console.log(state.user.prefs.theme); // 'dark'
console.log(next.user.prefs.theme);  // 'light'
const a = { list: [1, 2] };
const b = { ...a };
b.list.push(3);
console.log(a.list.length, b.list === a.list);

Spread copied the reference to the array, so a.list and b.list are the same array. Pushing through one name is visible through the other. Copy it with [...a.list] if you want independence.

Try it yourself

How deep is your copy?

const source = { top: 1, nested: { deep: 1 }, when: new Date(0) };

const spread = { ...source };
const json = JSON.parse(JSON.stringify(source));
const cloned = structuredClone(source);

spread.nested.deep = 99;

console.log('source after mutating spread:', source.nested.deep); // 99
console.log('json nested shared?', json.nested.deep);             // 1
console.log('json date type:', typeof json.when);                 // 'string'
console.log('clone date type:', cloned.when instanceof Date);      // true

Add a Map to source and compare all three copies. Which one keeps it a Map?

Exercises

Write a safe deepMerge

Write deepMerge(target, source) returning a new object. Plain objects merge recursively, everything else (numbers, strings, arrays) is replaced by the value from source, arrays are copied rather than shared, neither input is modified, and the keys __proto__, constructor and prototype are skipped so a hostile payload cannot pollute Object.prototype.

Check yourself

What does this log?
2 — Object.assign is shallow, exactly like spread. b.n is the same object as a.n, so writing through either name is visible through both.
Which copy keeps a Date as a Date and handles a circular reference?
structuredClone(x) — structuredClone understands Date, Map, Set, RegExp, typed arrays and cycles. The JSON round trip turns a Date into a string and throws on a cycle. Spread and Object.assign never go deeper than one level.
Why is a hand written deep merge a security concern?
A key named __proto__ in untrusted input can write onto Object.prototype — That is prototype pollution. JSON can carry an own property called __proto__, and a merge that assigns keys blindly may end up writing to the prototype every object inherits from. Skip those key names.

Common mistakes

  • Calling a spread copy a clone, then debugging a nested object that changed in two places.
  • Using the JSON round trip on data containing dates, Maps, undefined or Infinity.
  • Merging untrusted input without blocking __proto__ and friends.

Takeaways

  • Spread and Object.assign copy one level and share everything nested.
  • structuredClone is the built in deep copy, and it handles cycles.
  • The JSON round trip silently drops undefined, functions and symbols, and mangles dates and Infinity.
  • Immutable updates copy each object along the path you change and share the rest.