JavaScript Object Spread Overwrites Nested Objects: Merge Each Level
{ ...defaults, ...patch } replaces a nested object when both inputs contain the same top-level key. If patch.retry contains only attempts, the result does not retain defaults.retry.delayMs. Object spread merges properties at the level where you write it; it does not recursively merge their values.
For a known nested shape, merge that level explicitly: { ...defaults, ...patch, retry: { ...defaults.retry, ...patch.retry } }. Put the nested reconstruction after both outer spreads. Before using this for application settings, also decide whether an undefined value means “keep the default,” whether null is valid, and whether arrays replace or extend a list.
Find the level that was overwritten
MDN documents that the last assignment wins for repeated property names. With defaults.retry = { attempts: 3, delayMs: 1000 } and patch.retry = { attempts: 0 }, the outer object has one property named retry. Its last supplied value is the patch’s entire object.
Reversing the spreads restores the default object but discards the requested retry count. That changes precedence; it does not solve nested merging. Likewise, putting retry: { ...defaults.retry, ...patch.retry } before ...patch lets the later spread overwrite the reconstruction again.
The ECMAScript CopyDataProperties operation copies enumerable own property values. It does not recursively combine objects stored in those values. When a copied value is an object, the new property still refers to that object.
Check both the outgoing shape and references. A missing delayMs diagnoses replacement; result.retry === patch.retry identifies the shared object. A new outer wrapper alone does not establish independent nested settings.
Reproduce replacement and verify a settings policy
Save this fixture as merge-settings.mjs, then run node merge-settings.mjs. It was executed during article preparation on macOS with Node.js 24.13.0 on October 18, 2026. No package, network service, or framework is required.
The helper implements one small application contract: a boolean flag, nonnegative safe-integer retry values, and a dense list of strings. The caller supplies validated plain data records and a valid base configuration. The helper selects known output fields and validates their values; it is not a general validator for arbitrary JavaScript objects.
import assert from 'node:assert/strict';
const defaults = { enabled: true, retry: { attempts: 3, delayMs: 1000 }, servers: ['primary', 'backup'],};const patch = { retry: { attempts: 0 }, servers: ['local'] };const shallow = { ...defaults, ...patch };assert.deepEqual(shallow.retry, { attempts: 0 });assert.equal(Object.hasOwn(shallow.retry, 'delayMs'), false);assert.equal(shallow.retry, patch.retry);console.log(`shallow retry: ${JSON.stringify(shallow.retry)}`);
const reversed = { ...patch, ...defaults };assert.equal(reversed.retry.attempts, 3);console.log(`reversed attempts: ${reversed.retry.attempts}`);
// Contract: validated plain records; absent/undefined means keep default.// null is invalid; servers replaces the list, including an empty list.function mergeSettings(base, update) { if (update === null || typeof update !== 'object' || Array.isArray(update)) { throw new TypeError('update must be a record'); } if (update.retry !== undefined && (update.retry === null || typeof update.retry !== 'object' || Array.isArray(update.retry))) { throw new TypeError('retry must be a record'); } const retry = update.retry ?? {}; const enabled = update.enabled === undefined ? base.enabled : update.enabled; const attempts = retry.attempts === undefined ? base.retry.attempts : retry.attempts; const delayMs = retry.delayMs === undefined ? base.retry.delayMs : retry.delayMs; const servers = update.servers === undefined ? base.servers : update.servers; if (typeof enabled !== 'boolean' || !Number.isSafeInteger(attempts) || attempts < 0 || !Number.isSafeInteger(delayMs) || delayMs < 0 || !Array.isArray(servers) || !Array.from(servers).every(value => typeof value === 'string')) { throw new TypeError('invalid settings'); } return { enabled, retry: { attempts, delayMs }, servers: [...servers] };}
const merged = mergeSettings(defaults, patch);assert.deepEqual(merged, { enabled: true, retry: { attempts: 0, delayMs: 1000 }, servers: ['local'],});assert.notEqual(merged.retry, defaults.retry);assert.notEqual(merged.retry, patch.retry);assert.notEqual(merged.servers, patch.servers);merged.retry.attempts = 9;merged.servers.push('extra');assert.equal(defaults.retry.attempts, 3);assert.equal(patch.retry.attempts, 0);assert.deepEqual(patch.servers, ['local']);console.log('merged: default delay retained; source records and list unchanged');
for (const [label, update, expected] of [ ['omitted', {}, defaults], ['undefined', { retry: undefined, servers: undefined }, defaults], ['leaf undefined', { retry: { attempts: undefined } }, defaults], ['zero and false', { enabled: false, retry: { attempts: 0 } }, { enabled: false, retry: { attempts: 0, delayMs: 1000 }, servers: defaults.servers }], ['empty list', { servers: [] }, { ...defaults, servers: [] }],]) { assert.deepEqual(mergeSettings(defaults, update), expected); console.log(`policy: ${label} passed`);}for (const update of [null, { retry: null }, { retry: [] }, { retry: { attempts: null } }, { servers: null }, { servers: [, 'backup'] }]) { assert.throws(() => mergeSettings(defaults, update), TypeError);}const wrapper = { ...defaults };assert.equal(wrapper.retry, defaults.retry);assert.equal(wrapper.servers, defaults.servers);console.log('invalid inputs rejected; top-level copy still shares children');console.log('all assertions passed');Expected output:
shallow retry: {"attempts":0}reversed attempts: 3merged: default delay retained; source records and list unchangedpolicy: omitted passedpolicy: undefined passedpolicy: leaf undefined passedpolicy: zero and false passedpolicy: empty list passedinvalid inputs rejected; top-level copy still shares childrenall assertions passedThe mutation checks are separate from the merge checks. The helper creates a new retry record and copies the selected servers array. Because that array accepts strings only, copying its entries does not retain mutable element objects. The assertions then change the result and confirm that the patch and default retry counts and patch list remain intact.
Choose missing-value and array semantics explicitly
The helper uses field selection rather than a generic recursive merge. Its policy is deliberate and exercised above:
| Update value | Result in this helper | Reason |
|---|---|---|
Missing field or undefined | Keep its default | Treat it as no requested change |
retry: { attempts: 0 } | Keep zero and the default delay | Zero is an explicit value |
enabled: false | Keep false | A disabled flag is meaningful |
retry: null or attempts: null | Throw TypeError | Null is outside this settings schema |
servers: [] | Replace the list with an empty list | Empty is an explicit replacement |
servers: ['local'] | Replace, then copy the list | Do not concatenate default servers |
| Sparse servers array | Throw TypeError | A missing entry is not a server string |
A nested spread by itself would retain a leaf property explicitly set to undefined, replacing the default. The helper instead tests === undefined at each selected field. It also avoids value || default, which would discard the accepted zero and false values.
Spreading null or undefined into an object contributes no properties, as specified by CopyDataProperties. Therefore { ...base.retry, ...update.retry } would not reject retry: null on its own. The helper checks null first because its schema rejects it. If your application uses null to clear a section, implement that as a separate branch and change the expected output accordingly.
For JSON request bodies, remember that undefined object fields disappear during serialization. The in-memory distinction exercised here may no longer exist after transport. Define the update contract at the boundary where the patch is received.
Keep the fix limited to the shape you own
Use explicit nested reconstruction for a small, known object whose leaf values already meet the contract. Use a field-specific helper when omission, clearing, list replacement, and validation need different rules. Adding another nested section means defining that section’s policy too; this helper intentionally drops unselected fields instead of copying them through.
A deep copy is a different operation from combining defaults and updates. If independent data copying is the actual requirement, see the separate structuredClone diagnosis and data-only payload example. Cloning the patch alone would still not supply its missing default delay.
The published checks cover ordinary data records, primitive leaf values, string arrays, and the listed invalid inputs. They do not establish behavior for getters, proxies, class instances, cyclic graphs, framework reactive objects, or untrusted objects with executable property access. Parse and validate external input before calling this helper, and reproduce framework behavior in that framework’s runtime.
For the lost-setting symptom, assert the nested fields after applying the patch. For independence, assert references and mutate the result in a disposable fixture. Those checks answer different questions, and both are needed when the resulting settings will be changed later.
References: