This post explains a quiz originally shared as a LinkedIn poll.
🔹 The Question
let log = [];
const base = {
set value(v) {
log.push("set:" + v);
},
get value() {
log.push("get");
return 42;
}
};
const source = { value: 100 };
Object.assign(base, source);
const copy = { ...base };
console.log(log.join(", "));
console.log(copy.value);
Hint: Object.assign writes to the target using [[Set]], triggering setters. Spread reads from the source using [[Get]], creating plain data properties. Think about which side the accessor runs on.
Follow me for JavaScript puzzles and weekly curations of developer talks & insights at Talk::Overflow: https://talkoverflow.substack.com/
🔹 Solution
Correct Answer: A) set:100, get / 42
The output will be:
"set:100, get"42
🧠 How this works
This quiz exposes one of the most commonly misunderstood distinctions in JavaScript object manipulation: Object.assign invokes setters on the target, while spread (...) invokes getters on the source and creates plain data properties on the new object.
These two operations look interchangeable in everyday code, but they interact with property descriptors in fundamentally different ways — a distinction that causes real production bugs in state management systems, reactive frameworks, and configuration merging utilities.
The core mechanism:
Object.assign(target, source)iterates over the source's own enumerable properties, reads each value, then writes it to the target using the target's[[Set]]semantics. If the target has a setter for that property, the setter fires.{ ...source }iterates over the source's own enumerable properties using[[Get]]semantics, reads each value (triggering getters on the source), and creates a brand-new plain object with simple data properties — no getters or setters are copied.
🔍 Line-by-line explanation
- Setup:
let log = [];
const base = {
set value(v) { log.push("set:" + v); },
get value() { log.push("get"); return 42; }
};
const source = { value: 100 };
-
basehas an accessor propertyvaluewith both a getter and a setter -
sourcehas a plain data propertyvaluewith the number100 -
logtracks every accessor invocation
-
Object.assign(base, source);-
Object.assignreadssource.value→ gets100(plain data property, no getter) - Then it writes to
base.value = 100 -
basehas a setter forvalue, so the setter fires -
log.push("set:100")→ log is now["set:100"] -
Crucially: the value
100is never actually stored onbase. The setter received it but didn't persist it — there's no backing field. The getter still returns42.
-
-
const copy = { ...base };- Spread reads all own enumerable properties of
base -
base.valueis accessed → the getter fires -
log.push("get")→ log is now["set:100", "get"] - The getter returns
42 -
copyis created as{ value: 42 }— a plain data property, not an accessor
- Spread reads all own enumerable properties of
-
console.log(log.join(", "));- Outputs:
"set:100, get"
- Outputs:
-
console.log(copy.value);-
copy.valueis a plain data property with value42 - Outputs:
42 -
Note: The getter does NOT fire here —
copyhas a plain property, not an accessor
-
The non-obvious part: Most developers assume that since Object.assign(base, source) was called with { value: 100 }, then base.value is now 100. But the setter intercepted the write without storing anything, so the getter still returns its hardcoded 42. Then, when spread reads base.value, it calls the getter — not the stored value from the assign. The resulting copy object strips all accessor behavior entirely and holds a frozen snapshot (42) as a plain data property.
Common mistake:
// Developer assumes these are equivalent:
const merged1 = Object.assign(target, source); // Triggers target's setters
const merged2 = { ...target, ...source }; // Triggers target's getters, ignores setters
They are NOT equivalent when accessor properties are involved. The first mutates target via its setters. The second creates a new object from getter return values.
🔹 Key Takeaways
Object.assignuses[[Set]]on the target: It triggers setters on the destination object. If the setter doesn't store the value, the property's getter still returns its original value.Spread uses
[[Get]]on the source: It triggers getters on the source object and creates plain data properties on the new object. Accessor behavior is not transferred.Spread strips property descriptors: The resulting object from
{ ...obj }always has plain data properties — no getters, setters, non-enumerable flags, or non-configurable flags survive.Object.assigndoes not copy descriptors either: It reads values from the source (triggering getters) and writes them to the target (triggering setters). To truly copy descriptors, useObject.defineProperties(target, Object.getOwnPropertyDescriptors(source)).Setters without backing storage are a trap: If a setter doesn't persist the value (common in validation layers, logging interceptors, or reactive proxies),
Object.assignappears to succeed but the value is silently lost. The getter continues returning whatever it was designed to return.
Top comments (0)