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); // falseSpread 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 freshReach 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); // trueBoth 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); // 1structuredClone 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
| Technique | Depth | Survives | Use when |
|---|---|---|---|
{ ...obj } | one level | plain values and references | the default copy |
Object.assign(t, s) | one level | same as spread | you need to write into an existing target |
structuredClone(obj) | deep | Date, Map, Set, RegExp, cycles, typed arrays | real data you must fully detach |
JSON.parse(JSON.stringify(o)) | deep | strings, numbers, booleans, arrays, plain objects | the data is already JSON safe |
a hand written deepMerge | deep | whatever you code for | merging 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.assignis shallow, exactly like spread.b.nis the same object asa.n, so writing through either name is visible through both. - Which copy keeps a
Dateas aDateand handles a circular reference? structuredClone(x)—structuredCloneunderstands Date, Map, Set, RegExp, typed arrays and cycles. The JSON round trip turns a Date into a string and throws on a cycle. Spread andObject.assignnever go deeper than one level.- Why is a hand written deep merge a security concern?
- A key named
__proto__in untrusted input can write ontoObject.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,undefinedorInfinity. - Merging untrusted input without blocking
__proto__and friends.
Takeaways
- Spread and
Object.assigncopy one level and share everything nested. structuredCloneis the built in deep copy, and it handles cycles.- The JSON round trip silently drops
undefined, functions and symbols, and mangles dates andInfinity. - Immutable updates copy each object along the path you change and share the rest.