JSON Merge: Shallow Merge, Deep Merge, JSON Merge Patch & JSON Patch
Last updated:
JavaScript has two built-in shallow merge mechanisms: the spread operator ({ ...a, ...b }) and Object.assign({}, a, b) — both copy top-level properties from right to left, with later objects overwriting earlier ones, but neither recurses into nested objects. Deep merge requires recursion — for each key, if both values are non-null objects (not arrays), merge them recursively; otherwise, the right value wins. Libraries like lodash _.merge() and deepmerge handle edge cases (arrays, dates, circular refs) that naive recursion misses. This guide covers shallow vs deep merge behavior, custom deep merge with recursion, lodash _.merge() vs deepmerge library, JSON Merge Patch (RFC 7396 — null removes a key), JSON Patch (RFC 6902 — array of operations), and array merge strategies (replace, concat, union).
Shallow Merge: Spread Operator and Object.assign()
Shallow merge combines top-level properties from multiple objects into one — the spread operator ({ ...a, ...b }) and Object.assign({}, a, b) are equivalent. Properties from right-side objects overwrite matching keys from left-side objects. The critical limitation: if both a and b have a property whose value is itself an object, b's value replaces a's value entirely — the nested objects are not combined. This surprises developers who expect nested properties from a to survive when b only overrides some of them. Use shallow merge only when your objects are flat (no nested object values) or when you intentionally want nested objects replaced wholesale.
// ── Spread operator (ES2018) ─────────────────────────────────────
const a = { x: 1, y: 2, theme: { color: 'blue', size: 14 } }
const b = { y: 99, theme: { color: 'red' } }
const merged = { ...a, ...b }
// { x: 1, y: 99, theme: { color: 'red' } }
// ⚠ theme.size is GONE — b.theme replaced a.theme entirely
// ── Object.assign() — equivalent behavior ────────────────────────
const result = Object.assign({}, a, b)
// { x: 1, y: 99, theme: { color: 'red' } } — same loss of theme.size
// ── Multiple sources — rightmost wins ───────────────────────────
const defaults = { debug: false, timeout: 5000, retries: 3 }
const userPrefs = { timeout: 10000 }
const envFlags = { debug: true }
const config = { ...defaults, ...userPrefs, ...envFlags }
// { debug: true, timeout: 10000, retries: 3 }
// ✓ flat properties merge correctly
// ── Object.assign mutates the first argument ─────────────────────
const target = { a: 1 }
Object.assign(target, { b: 2 })
// target is now { a: 1, b: 2 } — mutated in place
// Spread always produces a new object — prefer spread to avoid mutation
// ── Spread does NOT copy prototype methods ───────────────────────
class Point { constructor(x, y) { this.x = x; this.y = y } distance() { ... } }
const p = new Point(3, 4)
const copy = { ...p }
// copy = { x: 3, y: 4 } — plain object, distance() method is gone
// Use Object.create + assign to preserve prototype:
// const copy = Object.assign(Object.create(Object.getPrototypeOf(p)), p)
// ── Spread and undefined ──────────────────────────────────────────
const c = { role: 'user', name: 'Alice' }
const override = { role: undefined }
const s = { ...c, ...override }
// { role: undefined, name: 'Alice' }
// ⚠ undefined DOES overwrite — role is now undefined, not 'user'
// lodash _.merge skips undefined source values; spread does notShallow merge is the right choice for flat objects: query parameters, simple options bags, HTTP headers, and React props. For configuration objects with nested sections (database config, logging config, feature flags), shallow merge silently drops nested properties that were present in the base object but absent in the override — this is the most common merge bug in JavaScript applications. Always verify your merge strategy against a representative data sample before shipping.
Deep Merge: Recursive Object Merging
Deep merge recurses into nested plain objects rather than replacing them. The algorithm: for each key in the source, if both the destination value and source value are plain objects (not arrays, not null, not Date), recursively merge them; otherwise, the source value wins. The plain-object check is the critical guard — without it, you will accidentally recurse into arrays (merging by index) or try to recurse into null (throwing). A minimal but production-safe implementation requires testing typeof val === 'object', val !== null, and !Array.isArray(val) for every value before recursing.
// ── isPlainObject guard ───────────────────────────────────────────
function isPlainObject(val) {
return (
typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
Object.prototype.toString.call(val) === '[object Object]'
)
}
// Rejects: null, arrays, Date, RegExp, Map, Set, class instances
// ── Minimal deep merge (immutable — returns new object) ───────────
function deepMerge(target, source) {
const result = { ...target }
for (const key of Object.keys(source)) {
if (isPlainObject(source[key]) && isPlainObject(target[key])) {
result[key] = deepMerge(target[key], source[key])
} else {
result[key] = source[key]
}
}
return result
}
const base = {
server: { host: 'localhost', port: 3000, tls: { cert: 'base.pem' } },
logging: { level: 'info', format: 'json' },
retries: 3,
}
const override = {
server: { port: 8080, tls: { key: 'prod.key' } },
logging: { level: 'warn' },
}
const merged = deepMerge(base, override)
// {
// server: { host: 'localhost', port: 8080, tls: { cert: 'base.pem', key: 'prod.key' } },
// logging: { level: 'warn', format: 'json' },
// retries: 3
// }
// ✓ server.host and tls.cert survive; port and tls.key are overridden
// ── Multi-source deep merge ────────────────────────────────────────
function deepMergeAll(...objects) {
return objects.reduce((acc, obj) => deepMerge(acc, obj), {})
}
const config = deepMergeAll(defaults, envOverrides, userOverrides)
// ── Circular reference risk ───────────────────────────────────────
// Naive recursion will stack-overflow on circular refs:
// const circular = {}; circular.self = circular
// deepMerge({}, circular) // → Maximum call stack size exceeded
// Guard with a WeakSet of visited objects:
function deepMergeSafe(target, source, visited = new WeakSet()) {
if (visited.has(source)) return source // break cycle
if (isPlainObject(source)) visited.add(source)
const result = { ...target }
for (const key of Object.keys(source)) {
if (isPlainObject(source[key]) && isPlainObject(target[key])) {
result[key] = deepMergeSafe(target[key], source[key], visited)
} else {
result[key] = source[key]
}
}
return result
}Write your own deep merge only when you need precise control over behavior — custom array strategy, prototype-aware merging, or performance-critical paths. For general application use, deepmerge or lodash _.merge() are more battle-tested. The most frequent mistake in hand-rolled deep merge is missing the !Array.isArray() check — arrays are objects in JavaScript, so without it your function merges [1, 2, 3] and ['a'] into { 0: "a", 1: 2, 2: 3 }, an object — not an array.
lodash _.merge() and the deepmerge Library
Two battle-tested libraries cover the majority of deep merge use cases. lodash _.merge(object, ...sources) mutates the first argument and returns it — always pass an empty object as the first argument to avoid mutation: _.merge({}, a, b). The deepmerge library takes a different approach: it is immutable by default, has no other dependencies, and exposes an arrayMerge option for customizing how arrays are combined. Choose deepmerge for immutability and array control; choose lodash if you already depend on it and want consistent behavior with other lodash utilities.
// ── lodash _.merge() ─────────────────────────────────────────────
import _ from 'lodash'
// or: import merge from 'lodash/merge' (tree-shakeable)
const a = { user: { name: 'Alice', roles: ['admin'] }, timeout: 5000 }
const b = { user: { roles: ['editor'], email: 'a@example.com' } }
// ⚠ mutates a:
_.merge(a, b)
// a is now { user: { name: 'Alice', roles: ['editor'], email: 'a@example.com' }, timeout: 5000 }
// Note: roles was replaced (index merge), not unioned
// ✓ immutable pattern — pass empty object first:
const merged = _.merge({}, a, b)
// ── lodash array merge: index-by-index ────────────────────────────
_.merge({ tags: ['js', 'node'] }, { tags: ['python'] })
// { tags: ['python', 'node'] }
// ⚠ index 0 overwritten; original 'node' at index 1 survives
// This is almost never what you want
// ── _.mergeWith() for custom array strategy ───────────────────────
const merged2 = _.mergeWith({}, a, b, (objVal, srcVal) => {
if (Array.isArray(objVal) && Array.isArray(srcVal)) {
return [...new Set([...objVal, ...srcVal])] // union
}
// return undefined → fall back to default merge behavior
})
// ── _.merge skips undefined source values ─────────────────────────
_.merge({ role: 'admin' }, { role: undefined })
// { role: 'admin' } — undefined is skipped, unlike spread
// ── deepmerge library ─────────────────────────────────────────────
import deepmerge from 'deepmerge'
// or: import { merge } from 'deepmerge'
const x = { db: { host: 'localhost', port: 5432 }, tags: ['base'] }
const y = { db: { port: 5433, ssl: true }, tags: ['env'] }
// Default: arrays are concatenated
deepmerge(x, y)
// { db: { host: 'localhost', port: 5433, ssl: true }, tags: ['base', 'env'] }
// Overwrite arrays (replace, not concat):
const overwriteMerge = (dest, src) => src
deepmerge(x, y, { arrayMerge: overwriteMerge })
// { db: { host: 'localhost', port: 5433, ssl: true }, tags: ['env'] }
// Union arrays (no duplicates):
const unionMerge = (dest, src) => [...new Set([...dest, ...src])]
deepmerge(x, y, { arrayMerge: unionMerge })
// { db: { host: 'localhost', port: 5433, ssl: true }, tags: ['base', 'env'] }
// Merge many objects at once:
deepmerge.all([defaults, envConfig, localConfig])lodash _.merge() silently handles circular references by detecting them and skipping the circular property — deepmerge will throw a stack overflow error on circular references. For most application data (config files, API response merging, Redux state merging), circular references do not occur, so deepmerge's immutability advantage outweighs this. The deepmerge package weighs ~1 KB minified and gzipped with zero dependencies; importing only lodash/merge (not all of lodash) adds about 5 KB. See also JSON.parse in JavaScript for parsing before merging.
JSON Merge Patch (RFC 7396): null Removes Keys
JSON Merge Patch is an HTTP PATCH format for partially updating a JSON resource. The patch document is itself a JSON object — keys present in the patch are applied to the target: a non-null value sets or replaces that property, and a null value removes the property entirely. Keys absent from the patch are left unchanged. This makes JSON Merge Patch human-readable and easy to generate — it looks just like the subset of the document you want to change. The tradeoff: you cannot use null as a meaningful value in a merge patch document, and you cannot partially update arrays (array values are always replaced wholesale).
// ── JSON Merge Patch algorithm (RFC 7396) ────────────────────────
function applyMergePatch(target, patch) {
// Work on a deep clone to avoid mutating the original
const result = JSON.parse(JSON.stringify(target))
for (const key of Object.keys(patch)) {
if (patch[key] === null) {
delete result[key] // null → remove the key
} else if (
typeof patch[key] === 'object' &&
typeof result[key] === 'object' &&
result[key] !== null
) {
result[key] = applyMergePatch(result[key], patch[key]) // recurse
} else {
result[key] = patch[key] // scalar or new key → set value
}
}
return result
}
// ── Example ───────────────────────────────────────────────────────
const resource = {
id: 42,
name: 'Alice',
role: 'user',
email: 'alice@example.com',
address: { city: 'London', country: 'GB' },
}
const patch = {
role: 'admin', // update: role changes to 'admin'
email: null, // remove: email key is deleted
address: { city: 'Paris' }, // partial update: only city changes
}
const updated = applyMergePatch(resource, patch)
// {
// id: 42,
// name: 'Alice',
// role: 'admin', ← updated
// // email gone ← removed (was null in patch)
// address: { city: 'Paris', country: 'GB' } ← city updated, country preserved
// }
// ── Sending a JSON Merge Patch via HTTP ───────────────────────────
await fetch('https://api.example.com/users/42', {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({ role: 'admin', email: null }),
})
// ── Limitation: cannot set a property TO null ─────────────────────
// This patch REMOVES the "score" key — it cannot SET score to null:
const badPatch = { score: null }
// To set a property to null, use JSON Patch (RFC 6902) instead:
// [{ "op": "replace", "path": "/score", "value": null }]
// ── Limitation: arrays are replaced, not partially updated ────────
const original = { tags: ['js', 'node', 'api'] }
const mergePatch = { tags: ['js', 'node'] }
applyMergePatch(original, mergePatch)
// { tags: ['js', 'node'] } — replaced entirely, not partially updatedJSON Merge Patch is the right choice for simple REST PATCH endpoints where clients send only the fields they want to change. It is supported by major API frameworks: Express, FastAPI, Rails, and Laravel all handle it natively once you parse the body with the correct content type. Use it when you need a PATCH that is easy to construct and read. Switch to JSON Patch when you need to set a field to null, reorder array elements, insert at a specific array index, or apply conditional updates using the test operation. For more on JSON schema validation, see that guide.
JSON Patch (RFC 6902): Operations Array
JSON Patch expresses document modifications as an ordered array of operation objects. Each operation specifies op (the operation name), path (a JSON Pointer identifying the target location), and for most operations, value. Operations are applied sequentially — if any operation fails (wrong type, missing path, failed test), the entire patch is rejected and the document is unchanged, making JSON Patch effectively atomic. This fine-grained control makes JSON Patch the right format for diff-based sync, collaborative editing, and any update that cannot be expressed as a simple overlay.
// ── JSON Patch operations ─────────────────────────────────────────
// op: "add" — add a value at path (or append to array with /-)
// op: "remove" — remove the value at path
// op: "replace" — replace the value at path (path must exist)
// op: "copy" — copy value from "from" path to "path"
// op: "move" — move value from "from" path to "path"
// op: "test" — assert the value at path equals "value"; fail if not
const patch = [
{ op: 'replace', path: '/role', value: 'admin' },
{ op: 'add', path: '/permissions', value: ['read', 'write'] },
{ op: 'remove', path: '/temporaryFlag' },
{ op: 'add', path: '/tags/-', value: 'verified' }, // append to array
{ op: 'add', path: '/tags/0', value: 'priority' }, // insert at index 0
{ op: 'copy', path: '/displayName', from: '/name' },
{ op: 'move', path: '/profile/fullName', from: '/name' },
{ op: 'test', path: '/version', value: 3 }, // assert version === 3
{ op: 'replace', path: '/version', value: 4 }, // then increment
]
// ── Applying JSON Patch in JavaScript: fast-json-patch ───────────
import jsonpatch from 'fast-json-patch'
const document = {
name: 'Alice', role: 'user', temporaryFlag: true,
tags: ['js'], version: 3,
}
// Apply the patch (mutates document by default):
const result = jsonpatch.applyPatch(document, patch).newDocument
// Validate patch without applying:
const errors = jsonpatch.validate(patch, document)
// errors is an array of validation errors, or empty array if valid
// Generate a patch from diff (observe changes):
const observer = jsonpatch.observe(document)
document.role = 'admin'
document.tags.push('node')
const generatedPatch = jsonpatch.generate(observer)
// [{ op: 'replace', path: '/role', value: 'admin' },
// { op: 'add', path: '/tags/-', value: 'node' }]
// ── Sending a JSON Patch via HTTP ─────────────────────────────────
await fetch('https://api.example.com/users/42', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json-patch+json' },
body: JSON.stringify([
{ op: 'replace', path: '/role', value: 'admin' },
{ op: 'replace', path: '/score', value: null }, // ✓ CAN set to null
]),
})
// ── JSON Pointer path syntax ──────────────────────────────────────
// / — root
// /name — top-level 'name' property
// /items/0 — first element of items array
// /items/- — append to items array (add only)
// /a~1b — property key "a/b" (/ encoded as ~1)
// /a~0b — property key "a~b" (~ encoded as ~0)The test operation is JSON Patch's most powerful and underused feature: it lets you implement optimistic concurrency control by asserting that a field has a specific value before applying subsequent operations. If the document has been modified since the client last read it (version number changed, ETag mismatch), the test fails and the entire patch is rejected — preventing lost-update conflicts without server-side locking. The fast-json-patch npm package implements RFC 6902 in full and is the de facto standard for JavaScript JSON Patch applications.
Array Merge Strategies: Replace, Concat, and Union
Arrays require explicit merge strategy decisions — unlike plain objects, there is no universally correct way to combine two arrays. Three strategies cover most use cases: replace (the right array completely overwrites the left), concat (arrays are joined, duplicates allowed), and union (arrays are joined and deduplicated). The right strategy depends on the semantics of the array: a list of feature flags should probably use union; a list of ordered migration steps should use replace; a list of log entries should use concat.
// ── Strategy 1: Replace (right wins) ─────────────────────────────
// Use when: the right config completely defines the array value
// Example: allowed HTTP methods, ordered pipeline steps
const a = { methods: ['GET', 'POST'], pipeline: ['auth', 'log'] }
const b = { methods: ['GET', 'POST', 'PUT'] }
// Spread / Object.assign → replace by default
const replaced = { ...a, ...b }
// { methods: ['GET', 'POST', 'PUT'], pipeline: ['auth', 'log'] }
// deepmerge with overwrite strategy:
import deepmerge from 'deepmerge'
const overwriteMerge = (_dest, src) => src
deepmerge(a, b, { arrayMerge: overwriteMerge })
// ── Strategy 2: Concat (append right to left) ─────────────────────
// Use when: order matters, duplicates are acceptable
// Example: log entries, migration list, message queue
const logs1 = { events: ['login', 'view'] }
const logs2 = { events: ['purchase', 'logout'] }
const concatMerge = (dest, src) => [...dest, ...src]
deepmerge(logs1, logs2, { arrayMerge: concatMerge })
// { events: ['login', 'view', 'purchase', 'logout'] }
// ── Strategy 3: Union (concat + deduplicate primitives) ───────────
// Use when: order matters less, duplicates are not meaningful
// Example: tags, feature flags, permission scopes
const base = { tags: ['js', 'node', 'api'] }
const override = { tags: ['python', 'api', 'ml'] }
const unionMerge = (dest, src) => [...new Set([...dest, ...src])]
deepmerge(base, override, { arrayMerge: unionMerge })
// { tags: ['js', 'node', 'api', 'python', 'ml'] }
// 'api' appears only once
// ── Union by key for arrays of objects ────────────────────────────
// Set identity doesn't work for objects — use a unique key
function unionByKey(key) {
return (dest, src) => {
const map = new Map(dest.map(item => [item[key], item]))
for (const item of src) {
map.set(item[key], { ...map.get(item[key]), ...item })
}
return [...map.values()]
}
}
const usersA = { users: [{ id: 1, role: 'user' }, { id: 2, role: 'admin' }] }
const usersB = { users: [{ id: 1, role: 'superadmin' }, { id: 3, role: 'user' }] }
deepmerge(usersA, usersB, { arrayMerge: unionByKey('id') })
// { users: [
// { id: 1, role: 'superadmin' }, ← merged by id
// { id: 2, role: 'admin' },
// { id: 3, role: 'user' },
// ]}
// ── Index merge (lodash default — rarely useful) ──────────────────
// lodash _.merge() merges arrays index-by-index:
import _ from 'lodash'
_.merge({ arr: [1, 2, 3] }, { arr: ['a', 'b'] })
// { arr: ['a', 'b', 3] } — indexes 0 and 1 overwritten, index 2 kept
// Almost never the intended behavior for application dataThe union-by-key pattern is particularly valuable for merging arrays of objects from different API layers — for example, combining base permission definitions with environment-specific overrides, where the same permission ID appears in both sources but with different settings. Avoid lodash's index merge behavior on arrays with semantic meaning; use _.mergeWith() with a custom customizer function whenever your data contains arrays. See also JSON best practices for broader guidance on working with JSON in JavaScript.
Merging JSON Configuration Files
Configuration layering — base defaults overridden by environment-specific values overridden by local developer settings — is the most common real-world deep merge use case. The pattern: read each layer from a JSON file, deep-merge them in ascending priority order (later wins), and expose the merged result as the application config. Each layer should contain only the values it needs to override — the base provides defaults for everything, and higher layers provide the minimum necessary changes for that environment.
// ── Config cascade: base < env < local ───────────────────────────
// config.base.json
{
"server": { "host": "0.0.0.0", "port": 3000, "tls": false },
"db": { "host": "localhost", "port": 5432, "name": "app_dev", "pool": 5 },
"logging": { "level": "info", "format": "json", "output": "stdout" },
"cache": { "ttl": 300, "maxItems": 1000 }
}
// config.production.json — only overrides
{
"server": { "tls": true },
"db": { "host": "prod-db.internal", "name": "app_prod", "pool": 20 },
"logging": { "level": "warn" }
}
// ── Node.js config loader ─────────────────────────────────────────
import fs from 'fs'
import path from 'path'
import _ from 'lodash'
function loadConfig(env = process.env.NODE_ENV ?? 'development') {
const read = (filename) => {
const full = path.resolve('config', filename)
if (!fs.existsSync(full)) return {}
return JSON.parse(fs.readFileSync(full, 'utf8'))
}
const base = read('config.base.json')
const envCfg = read(`config.${env}.json`)
// config.local.json is gitignored — developer overrides only
const local = read('config.local.json')
return _.merge({}, base, envCfg, local)
}
export const config = loadConfig()
// Usage: config.db.host, config.logging.level, etc.
// ── Validate the merged config with a schema ──────────────────────
import Ajv from 'ajv'
const schema = {
type: 'object',
required: ['server', 'db', 'logging'],
properties: {
server: {
type: 'object',
required: ['host', 'port'],
properties: {
host: { type: 'string' },
port: { type: 'integer', minimum: 1, maximum: 65535 },
tls: { type: 'boolean' },
},
},
db: {
type: 'object',
required: ['host', 'port', 'name'],
properties: {
host: { type: 'string' },
port: { type: 'integer' },
name: { type: 'string' },
pool: { type: 'integer', minimum: 1 },
},
},
},
}
const ajv = new Ajv()
const valid = ajv.validate(schema, config)
if (!valid) throw new Error('Invalid config: ' + ajv.errorsText())
// ── Next.js: environment-specific config ─────────────────────────
// next.config.ts handles env-specific values natively:
const nextConfig = {
env: {
API_URL: process.env.API_URL ?? 'http://localhost:3000',
},
// Merge framework defaults with your values using spread:
images: { ...defaultImageConfig, domains: ['cdn.example.com'] },
}
// ── Package.json deep merge for monorepos ─────────────────────────
// In a monorepo, merge workspace package.json with root:
const root = JSON.parse(fs.readFileSync('package.json', 'utf8'))
const pkg = JSON.parse(fs.readFileSync('packages/app/package.json', 'utf8'))
// Shallow merge scripts; deep merge dependencies:
const merged = {
...root,
...pkg,
dependencies: { ...root.dependencies, ...pkg.dependencies },
devDependencies: { ...root.devDependencies, ...pkg.devDependencies },
}Always validate the merged configuration against a schema before the application starts — a missing required key or type mismatch in config is far easier to diagnose at startup than as a runtime error inside a request handler. Use ajv or zod for schema validation; see the guide on JSON configuration files for a full treatment of config file patterns and the guide on JSON schema validation for AJV and Zod schema examples. Store only the base config in version control; add environment-specific and local config files to .gitignore and provide them through environment variables or secrets management in CI/CD.
Key Terms
- shallow merge
- A merge operation that copies only top-level properties from source objects into the target. Performed in JavaScript with the spread operator (
{ ...a, ...b }) orObject.assign({}, a, b). Properties from rightmost sources overwrite matching properties from earlier sources at the top level only — nested objects are replaced wholesale, not merged recursively. Appropriate for flat data structures; produces subtle data loss bugs when applied to objects with nested structure. - deep merge
- A merge operation that recurses into nested plain objects rather than replacing them. When both the target and source values for a key are plain objects (not null, not arrays, not class instances), deep merge applies itself recursively to those nested objects. Otherwise, the source value replaces the target value. Implemented by libraries like lodash
_.merge()anddeepmerge. Essential for merging configuration objects where different layers each provide different nested properties. - JSON Merge Patch (RFC 7396)
- An HTTP PATCH format defined in RFC 7396 where the patch document is a JSON object representing the desired changes. A key present in the patch with a non-null value sets or replaces that property in the target. A key with a
nullvalue removes that property from the target. Keys absent from the patch are left unchanged. Sent withContent-Type: application/merge-patch+json. Cannot set a property tonull(null always means delete) and cannot partially update arrays. - JSON Patch (RFC 6902)
- An HTTP PATCH format defined in RFC 6902 where the patch is an array of operation objects. Each operation has an
opfield (add,remove,replace,copy,move, ortest), apathfield using JSON Pointer syntax, and avaluefield for operations that need it. Operations are applied sequentially and atomically — if any fails, the document is unchanged. Sent withContent-Type: application/json-patch+json. More expressive than JSON Merge Patch: can set a property to null, insert at a specific array index, and usetestfor optimistic concurrency control. - lodash _.merge()
- A lodash utility that deep-merges source objects into the first (target) argument, mutating it. For each key in a source, if both destination and source values are plain objects,
_.merge()recurses. Arrays are merged index-by-index (not concatenated) — use_.mergeWith()with a custom merger for array union or concat behavior. Skipsundefinedsource values (unlike spread). Import onlylodash/merge(not all of lodash) for tree-shaking. Always pass an empty object as the first argument to avoid mutating your original:_.merge({}, a, b). - deepmerge
- A zero-dependency npm package (~1 KB minified) for deep-merging JavaScript objects. Unlike lodash
_.merge(), it is immutable by default — it returns a new object and does not mutate the inputs. Arrays are concatenated by default; pass anarrayMergefunction in the options object to control array behavior (replace, union, or custom). Usedeepmerge.all([a, b, c])to merge more than two objects. Does not handle circular references — throws a stack overflow; use lodash for circular-reference-safe merging.
FAQ
How do I merge two JSON objects in JavaScript?
For a shallow merge (flat objects with no nesting), use the spread operator: const merged = { ...a, ...b }. Properties from b overwrite matching properties in a. Object.assign({}, a, b) is equivalent. If your objects have nested properties (e.g., a settings sub-object), use a deep merge to avoid accidentally dropping nested keys — use lodash _.merge({}, a, b) or the deepmerge library: deepmerge(a, b). The key difference: { ...{ x: { a: 1 } }, ...{ x: { b: 2 } } } produces { x: { b: 2 } } (x.a is lost), while deep merge produces { x: { a: 1, b: 2 } }.
What is the difference between shallow and deep merge?
Shallow merge copies only top-level keys — if both objects have a key whose value is itself an object, the right-side object replaces the left-side object entirely. Deep merge recurses: if both values for a key are plain objects, they are merged recursively rather than replaced. Example: { ...{ a: { x: 1, y: 2 } }, ...{ a: { z: 3 } } } shallow-merges to { a: { z: 3 } } (x and y lost). Deep merge produces { a: { x: 1, y: 2, z: 3 } } (all properties preserved). Use shallow merge for flat option objects; use deep merge for layered configuration, nested state, or any object where nested keys from the base must survive the override.
How do I deep merge nested JSON objects?
Use lodash _.merge({}, target, source) — pass an empty object first to avoid mutating target. Alternatively, use the deepmerge library: deepmerge(target, source). For a hand-rolled solution, write a recursive function that checks isPlainObject() before recursing: if both the destination and source values for a key are plain objects (not null, not arrays), call deepMerge(dest[key], src[key]); otherwise set result[key] = src[key]. The isPlainObject guard — typeof val === 'object' && val !== null && !Array.isArray(val) — prevents accidental recursion into arrays, Dates, and other non-plain objects.
What is JSON Merge Patch?
JSON Merge Patch (RFC 7396) is an HTTP PATCH format where the patch body is a JSON object describing the desired changes. Any key in the patch with a non-null value is applied to the target document. Any key with a null value removes that key from the target. Keys absent from the patch are left unchanged. Send it with the header Content-Type: application/merge-patch+json. It is simple to use — the patch looks like a partial version of the document you want. The main limitation: you cannot set a property to null (null always means delete), and you cannot partially update an array (arrays are always replaced). For those cases, use JSON Patch (RFC 6902).
What is JSON Patch (RFC 6902)?
JSON Patch (RFC 6902) represents document modifications as an array of operation objects. Each operation has an op (add, remove, replace, copy, move, or test), a path in JSON Pointer format (e.g. /user/name), and a value where needed. Operations are applied in order and atomically — a failed operation rolls back all prior operations. Send with Content-Type: application/json-patch+json. Advantages over JSON Merge Patch: can set a property to null, insert at a specific array index (/items/0), append to an array (/items/-), and use test operations for optimistic concurrency control.
How do I merge JSON arrays without duplicates?
For arrays of primitives (strings, numbers), use a Set: const union = [...new Set([...arr1, ...arr2])]. Set identity removes structural duplicates for primitives. For arrays of objects, Set does not detect structural duplicates — two objects with identical properties are different references. Filter by a unique key instead: const seen = new Set(); const union = [...arr1, ...arr2].filter(item => { const k = item.id; if (seen.has(k)) return false; seen.add(k); return true; }. With the deepmerge library, pass a custom arrayMerge function: deepmerge(a, b, { arrayMerge: (dest, src) => [...new Set([...dest, ...src])] }).
How does lodash _.merge() handle nested objects?
_.merge() recursively merges source properties into the destination for any key where both values are plain objects. It mutates the first argument and returns it — always pass an empty object first to avoid mutation: _.merge({}, a, b). Key behavioral details: undefined source values are skipped (unlike spread, where undefined overwrites); arrays are merged index-by-index (not concatenated) — index 0 of source overwrites index 0 of destination, which is rarely what you want. Use _.mergeWith({}, a, b, customizer) and return a custom array merge in the customizer to concatenate or union arrays. _.merge() handles circular references silently by detecting and skipping them, unlike deepmerge which throws.
How do I merge JSON configuration files in Node.js?
Read each layer with JSON.parse(fs.readFileSync(path, 'utf8')) and deep-merge them in ascending priority order: _.merge({}, base, envConfig, localConfig). Use fs.existsSync() before reading optional files like local developer overrides (which are gitignored). The cascade pattern: a base file provides defaults for all settings; environment-specific files override only what differs per environment; a local file (gitignored) overrides for the developer's machine. Always validate the merged result against a JSON schema with AJV or Zod before starting the application — a missing required setting is clearer as a startup error than as a runtime TypeError: Cannot read properties of undefined.
Further reading and primary sources
- RFC 7396: JSON Merge Patch — Official IETF specification for JSON Merge Patch — the PATCH format where null removes keys
- RFC 6902: JSON Patch — Official IETF specification for JSON Patch — the operations-array PATCH format
- deepmerge on npm — Zero-dependency deep merge library with customizable array merge strategies
- lodash _.merge() documentation — lodash _.merge() and _.mergeWith() API reference with examples
- fast-json-patch on npm — Full RFC 6902 JSON Patch implementation for JavaScript with diff generation