Deep Merge JSON Objects in JavaScript

Last updated:

Shallow merge with Object.assign or spread ({...a, ...b}) replaces nested objects entirely. Deep merge recursively combines them. This guide covers four strategies — hand-rolled recursion, lodash.merge, the deepmerge package, and JSON Merge Patch (RFC 7396) — plus array strategies, TypeScript types, and prototype-pollution protection.

1. Why Shallow Merge Fails for Nested Objects

const defaults = {
  server: { port: 3000, timeout: 30 },
  logging: { level: 'info', format: 'json' },
}
const overrides = {
  server: { port: 8080 }, // only changing port
}

// ❌ Shallow merge — timeout is lost
const config = { ...defaults, ...overrides }
// { server: { port: 8080 }, logging: { level: 'info', format: 'json' } }
// ^^ server.timeout is gone!

// ✅ Deep merge — timeout preserved
const config = deepMerge(defaults, overrides)
// { server: { port: 8080, timeout: 30 }, logging: { level: 'info', format: 'json' } }

2. Hand-Rolled Deep Merge

For zero-dependency code or full control over edge-case behavior:

function deepMerge(target, source) {
  // Start with a deep clone of target to avoid mutating it
  const result = structuredClone(target)

  for (const key of Object.keys(source)) {
    // Security: skip prototype-polluting keys
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue

    const srcVal = source[key]
    const tgtVal = result[key]

    const isSrcObj = srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal)
    const isTgtObj = tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)

    if (isSrcObj && isTgtObj) {
      // Both are plain objects — recurse
      result[key] = deepMerge(tgtVal, srcVal)
    } else {
      // Primitive, array, null, or type mismatch — source wins
      result[key] = structuredClone(srcVal)
    }
  }

  return result
}

// Usage
const merged = deepMerge(defaults, overrides)

Array behavior: arrays are replaced (source wins). To concatenate instead, replace the structuredClone(srcVal) branch with: Array.isArray(srcVal) && Array.isArray(tgtVal) ? [...tgtVal, ...srcVal] : structuredClone(srcVal)

3. lodash.merge

import _ from 'lodash'
// or: import merge from 'lodash/merge'

// lodash.merge MUTATES the first argument
const result = _.merge({}, defaults, overrides) // safe: merge into empty object

// Array behavior: merge by index (NOT concatenate)
_.merge({ tags: ['a', 'b'] }, { tags: ['x'] })
// → { tags: ['x', 'b'] }  ← 'a' replaced by 'x', 'b' kept

// undefined from source is ignored (null is NOT)
_.merge({ a: 1 }, { a: undefined }) // → { a: 1 }
_.merge({ a: 1 }, { a: null })      // → { a: null }

// Merge multiple sources
_.merge({}, base, env, local)

lodash.merge is the right choice when lodash is already in your bundle. The main footgun is the by-index array merge — if you expected [a, b, x] and got[x, b], switch to deepmerge.

4. deepmerge Package

npm install deepmerge
import deepmerge from 'deepmerge'

// Arrays are concatenated by default
deepmerge({ tags: ['a', 'b'] }, { tags: ['x'] })
// → { tags: ['a', 'b', 'x'] }

// Merge 3+ objects
deepmerge.all([base, env, local])

// Custom array strategy: overwrite instead of concat
const overwriteMerge = (target, source) => source
deepmerge(a, b, { arrayMerge: overwriteMerge })

// Custom array strategy: merge by id field
const mergeById = (target, source, options) => {
  const byId = Object.fromEntries(target.map(x => [x.id, x]))
  source.forEach(x => { byId[x.id] = deepmerge(byId[x.id] ?? {}, x, options) })
  return Object.values(byId)
}
deepmerge(routesA, routesB, { arrayMerge: mergeById })

5. JSON Merge Patch (RFC 7396)

JSON Merge Patch is the standard for HTTP PATCH bodies. A patch value of nulldeletes the key; any other value recursively patches:

// Target document
{ "title": "Hello", "author": { "name": "Alice", "email": "a@example.com" }, "tags": ["a"] }

// Merge patch
{ "title": "World", "author": { "email": null }, "tags": ["b"] }

// Result (null deletes author.email; tags replaced, not merged)
{ "title": "World", "author": { "name": "Alice" }, "tags": ["b"] }
// Implementation
function applyMergePatch(target, patch) {
  if (typeof patch !== 'object' || patch === null) return patch
  const result = { ...target }
  for (const [key, value] of Object.entries(patch)) {
    if (value === null) {
      delete result[key]
    } else if (typeof value === 'object' && !Array.isArray(value)) {
      result[key] = applyMergePatch(result[key] ?? {}, value)
    } else {
      result[key] = value
    }
  }
  return result
}

// Or use the npm package: npm install json-merge-patch
import { apply } from 'json-merge-patch'
const updated = apply(original, patch)

6. Choosing the Right Strategy

ApproachArraysMutatesnull semanticsBest for
Hand-rolledReplaceNoReplaceZero deps, full control
lodash.mergeMerge by indexYes (first arg)Replacelodash already present
deepmergeConcatenateNoReplaceConfigurable array strategy
JSON Merge PatchReplaceNoDelete keyHTTP PATCH, partial updates

7. TypeScript and Prototype Pollution

// TypeScript — type-safe deep merge
import { merge } from 'ts-deepmerge' // npm install ts-deepmerge

// The return type is correctly inferred as DeepMerge<A, B>
const config = merge(defaults, overrides)
// config.server.port is typed as number

// Simpler approach for config merging (Partial override pattern)
function mergeConfig<T extends object>(defaults: T, overrides: Partial<T>): T {
  return deepMerge(defaults, overrides) as T
}

// Prototype pollution protection (always add when merging user input)
function isSafeKey(key: string): boolean {
  return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
}
// Use in the loop: if (!isSafeKey(key)) continue

Frequently Asked Questions

What is the difference between shallow merge and deep merge?

A shallow merge (Object.assign or spread {...a, ...b}) copies only top-level properties. If both objects have the same key and its value is an object, the source object completely replaces the target object — nested properties from the target are lost. For example: Object.assign({user: {name: "Alice", role: "admin"}}, {user: {name: "Bob"}}) results in {user: {name: "Bob"}} — the role is gone because the entire user object was replaced. A deep merge recursively processes nested objects: if both sides have an object at the same key, it merges those sub-objects instead of replacing them, so {user: {name: "Bob", role: "admin"}} is the result. Deep merge is what you want when combining configuration objects, applying partial updates to structured data, or merging defaults with user-provided settings.

How do I write a deep merge function in JavaScript without a library?

A minimal recursive deep merge in modern JavaScript: function deepMerge(target, source) { const result = structuredClone(target); for (const key of Object.keys(source)) { if (source[key] !== null && typeof source[key] === "object" && !Array.isArray(source[key]) && key in result && typeof result[key] === "object" && !Array.isArray(result[key])) { result[key] = deepMerge(result[key], source[key]); } else { result[key] = structuredClone(source[key]); } } return result; } Key decisions this implementation makes: (1) Arrays are replaced, not merged (source array wins). (2) null values from source replace the target key. (3) structuredClone ensures the result has no shared references with either input. (4) If source has a key and target does not, the key is added. If target has a key and source does not, it is kept unchanged. This handles ~90% of real use cases; add array concatenation or custom strategies for the remaining 10%.

What does lodash.merge do differently from a hand-rolled deep merge?

lodash.merge has several specific behaviors worth knowing: (1) Arrays are merged by index — merge([1,2,3], [4,5]) gives [4,5,3], not [4,5]. It does NOT concatenate. (2) undefined values from source are ignored — a source property set to undefined does not override the target. This differs from null, which does override. (3) Object.keys only — it only visits own enumerable properties, skipping prototype properties and Symbol keys. (4) It mutates the first argument — unlike a hand-rolled function that returns a new object, _.merge(target, source) modifies target in place. To avoid mutation, use _.merge({}, target, source). (5) It handles Dates, RegExps, Maps, and Sets by cloning them (pre-4.x had bugs here — use lodash 4.17.21+). For most config-merging tasks, lodash.merge is robust. The main footgun is the by-index array merge when you expected concatenation.

What is the deepmerge npm package and when should I use it?

deepmerge (npm install deepmerge) is a focused 500-byte package that does one thing: deep merge plain objects. Unlike lodash.merge, it concatenates arrays by default instead of merging by index — deepmerge({a: [1,2]}, {a: [3,4]}) gives {a: [1,2,3,4]}. The key feature is the options.arrayMerge callback, which lets you choose the array strategy: concatenate (default), overwrite, or any custom logic. deepmerge also exports deepmerge.all([obj1, obj2, obj3]) to merge more than two objects. Use deepmerge when you want configurable array strategies, a tiny dependency, and explicit non-mutation (it always returns a new object). Use lodash.merge when lodash is already in your bundle. Use a hand-rolled function when you want zero dependencies and full control over every edge case.

What is JSON Merge Patch (RFC 7396) and how is it different from deep merge?

JSON Merge Patch (RFC 7396) is a standardized format for expressing partial updates to a JSON document. The patch is itself a JSON object; applying it follows two rules: (1) if a key's value is null, delete that key from the target; (2) if a key's value is a non-null object, recursively merge; (3) otherwise, replace the target key with the patch value. This means null has special semantics — it is a deletion signal, not a value. A regular deep merge treats null as "replace with null." JSON Merge Patch is ideal for HTTP PATCH endpoints: the client sends only the changed fields, null fields are deleted, and nested objects are recursively patched. Libraries: json-merge-patch (npm), immer (using produce), or the one-line implementation: function applyMergePatch(target, patch) { const result = {...target}; for (const [k, v] of Object.entries(patch)) { v === null ? delete result[k] : typeof v === "object" ? result[k] = applyMergePatch(result[k] ?? {}, v) : result[k] = v; } return result; }

How should I handle arrays when deep merging configuration objects?

There is no single correct answer — it depends on what arrays in your config represent. Three common strategies: (1) Replace (overwrite): source array completely replaces target array. Best when arrays are atomic values like a list of plugin names or allowed origins — a user override should replace the entire list, not append to defaults. Use lodash.merge with a pre-conversion hack, or handle in hand-rolled code by not recursing into arrays. (2) Concatenate: append source elements after target elements. Best when arrays are additive like middleware chains or plugin lists where defaults + user additions are both needed. deepmerge default behavior, or use [..target, ...source] manually. (3) Merge by key: find elements in both arrays by an id field and merge them. Best for arrays of objects with identity (e.g. a list of route configs each with a unique path key). No standard library handles this automatically — implement a custom strategy or use lodash.mergeWith with a customizer function.

How do I deep merge objects in TypeScript with correct types?

The return type of a generic deep merge is tricky to express precisely — the TypeScript utility type DeepMerge<A, B> is not built into TypeScript (as of 5.x). Options: (1) Use type-fest: import { merge } from "ts-deepmerge" or from type-fest — these packages include proper TypeScript generics. (2) Cast: function deepMerge<T extends object>(target: T, source: Partial<T>): T { ... } — this works for merging a partial override into a full config object, which is the most common use case. (3) Use lodash.merge with @types/lodash — the types are broad (any) but functional. (4) Define your config type explicitly and use satisfies to check the merged result: const config = deepMerge(defaults, userConfig) satisfies Config. The satisfies keyword catches type errors in the result without widening the inferred type.

How do I prevent prototype pollution when deep merging untrusted JSON?

Prototype pollution is a security vulnerability where merging an attacker-controlled object containing a "__proto__" key modifies Object.prototype, affecting all objects in the runtime. The attack payload looks like: {"__proto__": {"isAdmin": true}}. After a naive deep merge, ({}).isAdmin === true. To prevent it: (1) Check keys before merging: if (key === "__proto__" || key === "constructor" || key === "prototype") continue. (2) Use Object.create(null) for intermediate objects — they have no prototype chain to pollute. (3) Use JSON.parse(JSON.stringify(input)) to sanitize the input first — this strips __proto__ keys because JSON.stringify does not serialize prototype-inherited properties. (4) Use a vetted library: lodash 4.17.21+ and deepmerge 4.3.1+ are patched against prototype pollution. (5) Never deep merge raw user input without sanitization. This applies to any server-side code that merges user-provided JSON into application config.

Further reading and primary sources