TechEarl

Merge and Deep-Clone Objects in JavaScript (Spread, Object.assign, structuredClone)

Merge objects in JavaScript with spread or Object.assign, and learn the three gotchas that bite: spread is shallow, the last value wins, and undefined overwrites. Plus structuredClone for a real deep clone and a small recursive deep-merge helper.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
Merge objects in JavaScript with spread and Object.assign, deep-clone with structuredClone, and deep-merge nested objects safely.

To merge two objects in JavaScript, spread them both into a new object literal: const merged = {...a, ...b}. Object.assign({}, a, b) does the same thing. Both copy each source object's own enumerable properties onto a target, left to right, so a key in b overwrites the same key in a. That one line covers most "combine two objects" cases, and it is the answer most searches land here for.

javascript
const defaults = { theme: "light", retries: 3 };
const overrides = { retries: 5 };

const config = { ...defaults, ...overrides };
// { theme: "light", retries: 5 }

// Object.assign mutates its first argument, so pass a fresh {} target:
const same = Object.assign({}, defaults, overrides);
// { theme: "light", retries: 5 }

Spread reads cleaner and is what I reach for. Object.assign earns its place when you genuinely want to mutate an existing object in place (Object.assign(target, patch)), since spread always builds a new object. Spread syntax has been Baseline since 2018 and Object.assign since 2015, so both are safe everywhere in 2026.

That is the easy part. The traps below are where the time goes.

The three gotchas of a spread merge

A shallow merge is correct far more often than people expect, but when it bites, it bites quietly. Three behaviors account for almost every "my merge did something weird" bug.

1. The merge is shallow, one level deep

Spread and Object.assign copy top-level keys by reference. Nested objects are not merged, the whole nested value from the right-hand object replaces the one on the left.

javascript
const a = { user: { name: "Sam", role: "admin" } };
const b = { user: { name: "Riley" } };

const merged = { ...a, ...b };
// { user: { name: "Riley" } }
// role is GONE: b.user replaced a.user wholesale, it was not deep-merged

If you expected role: "admin" to survive, you wanted a deep merge, which neither spread nor Object.assign does. Skip to the deep-merge section for that.

There is a related sharp edge: because nested objects are copied by reference, mutating merged.user also mutates a.user. They point at the same object. That is what a real clone fixes, covered next.

2. The last (right-most) value wins

On a key collision the right-most source wins. This is the whole point of an overrides pattern, but the ordering catches people who write { ...overrides, ...defaults } and wonder why their defaults clobbered the user's choices.

javascript
const merged = { ...{ size: "M" }, ...{ size: "L" }, ...{ size: "S" } };
// { size: "S" }, last spread wins

Put the object whose values should take priority on the right.

3. undefined overwrites, it does not skip

This is the subtle one. An explicit undefined in a later object overwrites an earlier real value, it does not fall through to the default.

javascript
const defaults = { timeout: 5000 };
const opts = { timeout: undefined };

const merged = { ...defaults, ...opts };
// { timeout: undefined }, NOT 5000

Spread copies every own enumerable key, and timeout is present on opts with the value undefined. Spread has no idea you meant "leave it unset." This is a common source of bugs when an options object is built from something like { timeout: userInput.timeout } and userInput.timeout happens to be undefined. If you want undefined values to be ignored, filter them out first or read them defensively with the patterns below.

A small te-prefixed helper to drop undefined values before merging:

javascript
const teDefined = (obj) =>
  Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));

const merged = { ...defaults, ...teDefined(opts) };
// { timeout: 5000 }: the undefined was stripped, the default survives

Note that null is a real value and is not stripped here, only undefined is. That is usually what you want, but decide deliberately.

Deep-clone with structuredClone

When you need an independent copy of an object, not a merge, the native answer in 2026 is structuredClone(obj). It recursively clones the whole structure, so mutating the copy never touches the original.

javascript
const original = { user: { name: "Sam" }, tags: new Set(["a", "b"]), when: new Date() };

const copy = structuredClone(original);
copy.user.name = "Riley";

original.user.name; // "Sam", untouched, fully independent

structuredClone is global in browsers (Chrome 98, Firefox 94, Safari 15.4, all since early 2022) and in Node.js since version 17, so it is broadly available now. It is the replacement for the old JSON.parse(JSON.stringify(obj)) trick, and it is strictly better: it preserves Date objects (JSON turns them into strings), Map and Set, ArrayBuffer, and typed arrays, and it handles cyclic references without blowing the stack.

What it cannot clone matters just as much. structuredClone throws a DataCloneError (a DOMException) if any part of the value is a function or a DOM node. It does not throw on a class instance, but it does not preserve it either: the own enumerable properties are copied, the prototype is dropped and the methods go with it, so a User instance comes back as a plain object. (Error, Date, Map, Set, RegExp, and typed arrays are all serializable and clone fine; it is functions, DOM nodes, and prototype-bearing instances that bite.) So it is perfect for plain data, config trees, API responses, and wrong for anything carrying behavior.

javascript
structuredClone({ run: () => {} });
// DataCloneError: () => {} could not be cloned.

If you only need a one-level copy and the values are primitives or you accept shared references, { ...obj } is faster and fine. Reach for structuredClone specifically when you need a genuinely deep, independent copy of plain data.

Deep-merge: there is no native one

JavaScript has no built-in deep merge. structuredClone clones, spread merges shallowly, and nothing in the standard library recursively merges two objects. You either write a small helper or pull in a library.

Leading with native, here is a compact recursive merger. It walks both objects, and where both sides hold a plain object at the same key, it merges them rather than letting the right side replace the left:

javascript
const teIsPlainObject = (v) =>
  v !== null && typeof v === "object" && !Array.isArray(v);

function teDeepMerge(target, source) {
  const out = { ...target };
  for (const key of Object.keys(source)) {
    if (teIsPlainObject(out[key]) && teIsPlainObject(source[key])) {
      out[key] = teDeepMerge(out[key], source[key]);
    } else {
      out[key] = source[key];
    }
  }
  return out;
}

const a = { user: { name: "Sam", role: "admin" } };
const b = { user: { name: "Riley" } };

teDeepMerge(a, b);
// { user: { name: "Riley", role: "admin" } }, role survives

This deliberately keeps the scope small: it deep-merges plain objects, replaces everything else (arrays included, since "merge two arrays" is its own question, see below), and copies the top level rather than mutating target. For truly independent output, wrap the result in structuredClone, or clone the inputs first. It does not guard against cyclic references, so do not point it at the DOM or a circular graph.

If you want a battle-tested version with array-handling options and cycle safety, lodash's merge (import merge from "lodash/merge") is the standard library choice, and lodash.merge mutates its destination while lodash.mergeWith lets you customize how each key combines. I lead with the native helper because for config and API-shape merging the twenty lines above are usually all you need, and one fewer dependency is one fewer thing to keep current.

Reading nested values safely

Half the reason people deep-merge is to safely read a value that may or may not be there. Optional chaining (?.) handles that read without any merge at all, short-circuiting to undefined the moment a link in the chain is missing instead of throwing "cannot read properties of undefined."

javascript
const res = { data: { user: { profile: null } } };

res.data?.user?.profile?.avatar;      // undefined, no throw
res.data?.user?.profile?.avatar ?? "default.png";  // "default.png"

Pair it with the nullish coalescing operator (??) to supply a fallback only when the value is null or undefined, not when it is 0 or an empty string (which || would wrongly replace). Optional chaining and ?? have both been Baseline since 2020, and together they retire the hand-rolled "split on dots and walk the object" utilities that used to fill this gap. For writing into a deep path, build the nested literal explicitly or let your deep-merge helper layer it in.

See also

Sources

Authoritative references this article was fact-checked against.

TagsJavaScriptobjectsspread operatorObject.assignstructuredClonedeep clonedeep mergeoptional chaining

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Stream a fetch() Response in JavaScript (NDJSON, line-by-line)

Read a fetch() response as it arrives instead of buffering the whole body. Async-iterate response.body, decode with TextDecoderStream, and split NDJSON line by line with a dependency-free TransformStream. The same pattern that consumes SSE and streaming LLM token responses.