skip to content

A config object built as `{...defaults, ...userOptions}` loses nested defaults and, when the source was a class instance, its methods too. What does object spread actually copy, and why do both symptoms follow?

level: seniorimportance: should knowfreq 50%

answer

  1. own and enumerable only
  2. merge happens between keys
  3. prototype is not part of the object
  4. later source wins, undefined included

basics

~20 s

Object spread copies own enumerable properties, one level deep, later sources overwriting earlier ones per key. A nested key is replaced wholesale rather than merged, and nothing on the prototype — including class methods — comes along.

solid answer

~40 s

`{...a, ...b}` walks each source's own enumerable properties, string and symbol keys alike, and defines them on a fresh plain object in order, so a key present in both takes `b`'s value. That per-key overwrite is the first symptom: if `defaults.retry` is `{count: 3, delay: 100}` and the user passes `retry: {count: 5}`, the whole nested object is replaced and `delay` disappears — spread never merges recursively. The second symptom comes from *own*: the prototype is not copied, so spreading a class instance yields a plain object with the instance fields and no methods, and `instanceof` is false. Two related traps: an explicit `undefined` in a later source still overwrites, and getters are invoked during the copy, so the result holds a frozen snapshot value rather than the accessor.

code

javascript · 14 lines
javascript
class Client {
  constructor(url) { this.url = url; }
  send() { return 'sent'; }
}
const copy = { ...new Client('/api') };
console.log(copy.url, typeof copy.send, copy instanceof Client); // /api undefined false

const defaults = { retry: { count: 3, delay: 100 }, verbose: false };
const user = { retry: { count: 5 }, verbose: undefined };
console.log({ ...defaults, ...user });
// { retry: { count: 5 }, verbose: undefined } — delay lost, verbose clobbered

console.log({ ...defaults, ...user, retry: { ...defaults.retry, ...user.retry } });
// { retry: { count: 5, delay: 100 }, verbose: undefined }

go deeper

for a junior

Know that {...a, ...b} combines two objects and that keys in b win. Be able to say the merge is one level deep, so a nested object in b replaces the one in a rather than combining with it.

for a middle

Explain 'own enumerable': inherited prototype methods and non-enumerable properties are skipped, symbol keys are not. Note that getters are invoked during the copy and that an explicit undefined still overwrites.

for a senior

Diagnose a real options-merge regression from the symptom — a nested default vanishing after a caller supplied a partial object — and prescribe either an explicit per-level merge or a flatter options shape rather than reaching for a deep merge reflexively.

for a principal

Decide the contract for configuration across services: whether partial nested overrides are supported at all, how absence is represented so undefined cannot clobber defaults, and whether options objects are plain data by policy so the prototype question never arises.

## The rule spread follows Object spread copies each source's **own enumerable** properties onto a fresh object literal, in source order, defining each key as a plain data property. Everything surprising about it falls out of that one sentence. ### Later wins, per key, at the top level only ```js const defaults = { retry: { count: 3, delay: 100 }, verbose: false }; const userOptions = { retry: { count: 5 } }; const config = { ...defaults, ...userOptions }; config.retry; // { count: 5 } — delay is gone config.verbose; // false — untouched key survives ``` Merging happens between keys, never inside them. `retry` existed in both sources, so the second value replaced the first outright. A key that appears in only one source passes through, which is why the pattern looks correct on flat objects and quietly loses data the moment options grow a nested level. If you need a nested merge you write it yourself, level by level: ```js const merged = { ...defaults, ...userOptions, retry: { ...defaults.retry, ...userOptions.retry }, }; ``` Once that list of hand-merged keys grows past two or three, a generic deep-merge helper or a flattened options shape is the honest answer. ### `undefined` is a value, not an absence ```js const opts = { timeout: undefined }; const c = { timeout: 30, ...opts }; c.timeout; // undefined — the default was overwritten ``` This bites whenever options are assembled programmatically and an unset field is materialized as `undefined` rather than omitted. Guard by stripping undefined values before spreading, or by applying `??` per field after the merge. ### Own means own: the prototype stays behind ```js class Client { constructor(url) { this.url = url; } send() { /* ... */ } } const c = new Client('/api'); const copy = { ...c }; copy.url; // '/api' — instance field copied copy.send; // undefined — method lives on Client.prototype copy instanceof Client; // false — copy is a plain object ``` Class methods are properties of the prototype, not of the instance, so they are not own properties and spread never sees them. The result is a plain object that carries the data and none of the behaviour — which is fine when you *wanted* a data snapshot, and a real defect when the value is passed on to something that expects to call methods on it. Class fields declared as instance fields (including arrow-function fields) *are* own properties and do come along, which makes the outcome look inconsistent unless you know the rule. Non-enumerable own properties are skipped for the same reason: `Object.defineProperty(obj, 'secret', { value: 1, enumerable: false })` survives no spread. Own enumerable **symbol** keys, by contrast, are copied — spread is not a string-key-only operation. ### Getters are invoked, not carried ```js let reads = 0; const source = { get now() { reads++; return Date.now(); } }; const snap = { ...source }; // reads === 1; snap.now is a fixed number, not an accessor ``` Spread reads each property's value and defines a data property with it. An accessor therefore runs once at copy time and becomes a frozen result. That is desirable for snapshots and wrong for lazily computed or live fields — and it means spreading an object can have side effects if its getters do. ## Spread versus `Object.assign` They are close but not identical. `Object.assign(target, source)` mutates an existing target and *assigns* each key, which triggers any setter already defined on the target and respects a non-writable property by throwing in strict mode. Object spread always creates a fresh object and *defines* each key, so no setter on any prototype interferes. When you are building a new object, spread is the safer of the two; `Object.assign` is for deliberately mutating something that already exists. ## How to answer Give the one-line rule first — own enumerable properties, one level, later sources win — and then derive each symptom from it: nested keys replaced because merging is per key; methods missing because they are inherited, not own; stale snapshots because getters are read eagerly; defaults clobbered because `undefined` is a value. Closing with the explicit per-level merge, and the observation that it stops scaling, is what makes this read as production experience rather than syntax recall.

  • How does object spread differ from Object.assign for building a new object?
    Spread creates a fresh object and *defines* each property, so no setter can intercept the write. `Object.assign` mutates an existing target and *assigns*, so a setter on the target or its prototype runs, and a non-writable property throws in strict mode. Both copy only own enumerable properties, one level deep.
  • How do you stop an explicitly undefined user option from wiping a default?
    Strip the undefined entries before merging — build the override object from only the keys that were actually supplied — or apply the fallback after the merge with `??` per field. Relying on the spread to skip undefined never works, because spread copies the property that exists, whatever its value.
  • Why do arrow-function class fields survive a spread when ordinary methods do not?
    An ordinary method is installed on the class's prototype object, so it is inherited rather than own and spread skips it. A class field — including one initialized to an arrow function — is created on the instance itself during construction, making it an own enumerable property that spread copies like any other data field.

saying these in an interview costs you the question

  • Says spread deep-merges nested option objects
  • Expects a spread class instance to keep its methods
  • Thinks undefined values are skipped during the merge
  • Believes spread copies getters as accessors
  • Claims spread and Object.assign are exactly equivalent

context