JAVASCRIPT / OBJECTS AND PROPERTY MECHANICS
Copying objects with spread and Object.assign
Copy and merge objects with spread and Object.assign, and predict which properties, getters, and nested references survive the copy.
What you will learn
- Merge objects with spread, knowing later sources overwrite earlier keys
- Predict which mutations leak between a copy and its source object
- Pick Object.assign when you need to write into an existing target
- Spot dropped prototypes, hidden properties, and flattened getters in a copy
Understanding Copying objects with spread and Object.assign
Spread in an object literal and Object.assign do the same core job: they walk a source's own enumerable keys, string and symbol alike, read each value once, and put that value on another object. The useful mental model is that you are copying a list of key/value pairs, not the things those values point at. A number or string value is genuinely duplicated; an object or array value is a reference, and copying a reference gives you a second arrow to the same object. Sources are processed left to right, so both { ...defaults, ...overrides } and Object.assign({}, defaults, overrides) let the rightmost source win on any shared key.
The two differ in how they write. Spread creates a brand-new plain object and defines own data properties directly on it, so it never consults a setter and never fails on an existing property. Object.assign takes its first argument as the target it mutates and returns, and it writes with ordinary assignment, which means a setter on the target runs, an inherited setter can intercept the write, and a non-writable property throws a TypeError in strict mode. Reach for spread when you want a fresh object and for Object.assign when the target already exists and its own write behaviour should apply.
What both forms drop is worth memorising, because the loss is silent. Reading a source getter calls it, so the copy stores whatever it returned at that instant as an ordinary value, frozen from then on. Non-enumerable properties are skipped and the prototype is not carried over, so spreading a class instance yields a plain object with the fields but none of the methods. And because copying stops after one level, a nested object is still shared, which is why editing copy.limits.rows shows up in the original.
const defaults = { theme: "light", limits: { rows: 10 } };
const overrides = { theme: "dark" };
const merged = { ...defaults, ...overrides };
console.log(merged);
const target = { id: 1 };
const returned = Object.assign(target, defaults, overrides);
console.log(returned === target, target.theme);
merged.limits.rows = 50;
console.log(defaults.limits.rows, target.limits.rows);
console.log(merged.limits === defaults.limits, merged.limits === target.limits);Both forms copy one level of a source's own enumerable properties, so references inside those properties stay shared and getters turn into fixed values.
Worked examples
Getters become snapshots
Copying reads each source property, so a getter runs once and its result is stored as a plain value.
const cart = {
items: [2, 3],
get total() {
return this.items.reduce((sum, n) => sum + n, 0);
}
};
const copy = { ...cart };
console.log(copy.total);
copy.items.push(10);
console.log(cart.total, copy.total);
const d = Object.getOwnPropertyDescriptor(copy, "total");
console.log(d.get, d.value);Example explained
Line 1{ ...cart } reads total with a normal property get, so the getter runs once and 5 is stored.
Line 2copy.items is the same array as cart.items, so push is visible through both objects.
Line 3cart.total recomputes to 15 while copy.total stays 5, because the copy holds a number, not a getter.
Line 4The descriptor proves it: get is undefined and value is 5, so nothing will ever recompute.
Spreading a class instance
Shows that the prototype and non-enumerable properties are not part of a shallow copy.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return `(${this.x}, ${this.y})`;
}
}
const p = new Point(1, 2);
Object.defineProperty(p, "id", { value: 7, enumerable: false });
const shallow = { ...p };
console.log(shallow);
console.log("id" in shallow, p.id);
console.log(String(p), String(shallow));
console.log(shallow instanceof Point);Example explained
Line 1Only x and y appear: id was defined as non-enumerable, so the copy skipped it entirely.
Line 2"id" in shallow is false while p.id is still 7, which is how hidden bookkeeping fields vanish.
Line 3String(shallow) falls through to Object.prototype.toString because the copy's prototype is Object.prototype, not Point.prototype.
Line 4shallow instanceof Point is false: the data survived, the type identity did not.
Assigning versus defining
Object.assign triggers a setter on the target, while spread defines a plain data property instead.
const target = {
_name: "unset",
set name(v) {
console.log("setter ran with", JSON.stringify(v));
this._name = v.trim();
},
get name() {
return this._name;
}
};
Object.assign(target, { name: " Ada " });
console.log(target.name);
const spreadCopy = { ...target, name: " Ada " };
console.log(JSON.stringify(spreadCopy.name));
console.log(Object.getOwnPropertyDescriptor(spreadCopy, "name").writable);Example explained
Line 1Object.assign writes with ordinary assignment, so the target's name setter runs and trims the value.
Line 2target.name reads back "Ada" because the setter stored the trimmed string in _name.
Line 3The literal defines an own property rather than assigning, so no setter runs and the untrimmed string survives.
Line 4The copy's name is a writable data property; the accessor pair from target is gone.
Important notes
Nullish sources are ignored rather than fatal: { ...undefined } and Object.assign({}, null) both give {}, so a missing source silently contributes nothing.
Object.assign writes in order and stops at the first failing write, so a throw on a frozen or non-writable target can leave the target half updated.
Common mistakes
Treating { ...user } as a full copy and then editing copy.address.city, which changes the original user's address and produces a bug far from the copy line.
Writing Object.assign(user, defaults) to fill in missing fields: the argument order makes the defaults overwrite real values and mutates user itself. Object.assign({}, defaults, user) is the version that fills gaps.
Spreading a class instance and then calling a method on the copy, which fails with TypeError: copy.save is not a function because methods live on the prototype, not on own properties.
Try it yourself
Change, predict, then run
In a browser console create const config = { retries: 3, ui: { dark: false } } and const copy = { ...config }, then set copy.retries = 5 and copy.ui.dark = true and log config. Explain why only one edit shows up, then rewrite the copy so neither leaks.
Open the JavaScript workspaceCheck your understanding
You copy with const copy = Object.assign({}, settings), then run copy.theme = 'dark' and copy.limits.rows = 50. Which changes are visible through settings?
- Both, because Object.assign links the copy back to the source
- Only limits.rows, because the copy holds the same reference to the limits object
- Neither, because Object.assign produced an independent copy
- Only theme, because objects are cloned but primitives are shared
Show answer
Assigning copy.theme only touches an own property on the copy, so settings.theme is untouched. But copy.limits and settings.limits are two names for one object, so a mutation through either is visible from both. Option 2 is tempting because Object.assign is described as making a copy; it does, but only of the top-level key/value list.