DEV Community

ValPetal Tech Labs
ValPetal Tech Labs

Posted on

Javascript Question of the Day #16 [Talk::Overflow]

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.

  2. { ...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

  1. Setup:
   let log = [];
   const base = {
     set value(v) { log.push("set:" + v); },
     get value()  { log.push("get"); return 42; }
   };
   const source = { value: 100 };
Enter fullscreen mode Exit fullscreen mode
  • base has an accessor property value with both a getter and a setter
  • source has a plain data property value with the number 100
  • log tracks every accessor invocation
  1. Object.assign(base, source);

    • Object.assign reads source.value → gets 100 (plain data property, no getter)
    • Then it writes to base.value = 100
    • base has a setter for value, so the setter fires
    • log.push("set:100") → log is now ["set:100"]
    • Crucially: the value 100 is never actually stored on base. The setter received it but didn't persist it — there's no backing field. The getter still returns 42.
  2. const copy = { ...base };

    • Spread reads all own enumerable properties of base
    • base.value is accessed → the getter fires
    • log.push("get") → log is now ["set:100", "get"]
    • The getter returns 42
    • copy is created as { value: 42 } — a plain data property, not an accessor
  3. console.log(log.join(", "));

    • Outputs: "set:100, get"
  4. console.log(copy.value);

    • copy.value is a plain data property with value 42
    • Outputs: 42
    • Note: The getter does NOT fire here — copy has 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
Enter fullscreen mode Exit fullscreen mode

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

  1. Object.assign uses [[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.

  2. 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.

  3. 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.

  4. Object.assign does not copy descriptors either: It reads values from the source (triggering getters) and writes them to the target (triggering setters). To truly copy descriptors, use Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)).

  5. 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.assign appears to succeed but the value is silently lost. The getter continues returning whatever it was designed to return.

Top comments (0)