ya-merge
Yet another deep merge function.
merge(...objects) folds any number of values left to right and returns a new object; the rightmost value always wins on conflicts. Unlike Object.assign or {...a, ...b}, nested objects are merged recursively instead of being replaced wholesale.
import merge from "ya-merge";
merge({a: {x: 1}}, {a: {y: 2}}) // { a: { x: 1, y: 2 } }
Object.assign({a: {x: 1}}, {a: {y: 2}}) // { a: { y: 2 } } <- nested x is lost
Install
npm install --save ya-merge
The package is ESM-only ("type": "module"). In CommonJS use a dynamic import:
const {default: merge} = await import("ya-merge")
From a CDN
The published package is a plain ES module with no dependencies and no build step, so every npm CDN serves it as is — no bundler, no install:
<script type="module">
import merge from "https://unpkg.com/ya-merge"
console.log(merge({a: {x: 1}}, {a: {y: 2}}))
</script>
The same file is available from the other usual mirrors:
| CDN | URL |
|---|---|
| unpkg | https://unpkg.com/ya-merge |
| jsDelivr | https://cdn.jsdelivr.net/npm/ya-merge |
| esm.sh | https://esm.sh/ya-merge |
Usage
import merge from "ya-merge";
const defaults = {
server: {host: "localhost", port: 8080},
retries: 3,
tags: ["default"],
}
const config = {
server: {port: 3000},
tags: ["prod"],
}
merge(defaults, config)
// {
// server: {host: "localhost", port: 3000},
// retries: 3,
// tags: ["prod"],
// }
Any number of arguments is supported and they are applied in order:
merge({a: 1}, {b: 2}, {c: 3}) // { a: 1, b: 2, c: 3 }
merge({x: 0}, {x: 1}, {x: 2}) // { x: 2 }
Merge rules
Left (a) |
Right (b) |
merge(a, b) |
|---|---|---|
| plain object | plain object | new object with the union of keys, values merged recursively |
| array | array | merged element by element, length is max(a.length, b.length) |
| array | anything else | b is merged into every element of a |
| anything else | array | a is merged into every element of b |
| anything | undefined |
a is kept |
| anything | null |
null — b wipes the branch instead of merging into it |
undefined/null/0/""/false |
anything | b is returned as is |
| anything | primitive or function | b is returned as is |
different typeof |
b is returned as is |
|
| anything | Date, Map, Set, WeakMap, WeakSet, DOM Element |
b is returned by reference, never merged into |
The rules apply to the fold as a whole, so a rightmost value that replaces rather than merges wins over everything before it:
merge({a: 1}, {b: 2}, 7) // 7
merge({a: 1}, false) // false
merge({a: 1}, {b: 2}, new Date(0)) // the Date
Arrays are merged by index, not concatenated
merge([1, 2, 3], [9]) // [9, 2, 3]
merge([1, 2], [9, 8, 7]) // [9, 8, 7]
merge([{a: 1}, {a: 2}], [{b: 1}, {b: 2}])
// [ {a: 1, b: 1}, {a: 2, b: 2} ]
Use a hole (or undefined) to keep an element untouched:
merge([{a: 1}, {a: 2}], [undefined, {b: 2}])
// [ {a: 1}, {a: 2, b: 2} ]
If you want concatenation, do it yourself: merge(a, b) never appends.
Mixing an array with a non-array broadcasts
The non-array side is merged into each element of the array. This is handy for stamping shared fields onto a collection:
merge([{id: 1}, {id: 2}], {active: true})
// [ {id: 1, active: true}, {id: 2, active: true} ]
merge({active: true}, [{id: 1}, {id: 2}])
// [ {active: true, id: 1}, {active: true, id: 2} ]
Built-in objects are atomic
Date, Map, Set, WeakMap, WeakSet and DOM Element instances are treated as opaque values — the right-hand one replaces the left-hand one by reference, it is not copied or merged into:
const d = new Date("2021-01-01")
merge({at: new Date("2020-01-01")}, {at: d}).at === d // true
The same holds for class instances whose toString() differs from each other — they are replaced rather than merged.
Design notes
merge is deliberately small, and these are the trade-offs that come with it.
Not a deep clone. Sub-trees that exist on only one side are carried over by reference, so the result shares structure with the inputs:
const a = {keep: {deep: 1}}
const result = merge(a, {other: 2})
result.keep === a.keep // true — mutating one mutates the other
The arguments themselves are never mutated, and branches present on both sides are rebuilt as new objects.
If you need a result that shares nothing with its inputs, wrap the merge in structuredClone:
const isolated = structuredClone(merge(a, b))
isolated.keep === a.keep // false
That also upgrades the atomics — Date, Map, Set and RegExp come out as real clones instead of being carried over by reference. In exchange it throws DataCloneError on function values and on WeakMap/WeakSet, so it only fits data that is structured-cloneable.
Prototypes are dropped. The result of merging two objects is always a plain object, so class instances lose their prototype and methods:
class Point { constructor(x) { this.x = x } len() { return this.x } }
const p = merge(new Point(1), {y: 2})
p.constructor.name // "Object"
p.len // undefined
Only own enumerable string keys are copied. Symbol keys and non-enumerable properties are dropped, and getters are evaluated and flattened into plain values.
Peer circular references are not supported. merge keeps no record of what it has already visited, so a cycle that both arguments reach along the same chain of keys makes the recursion loop until the stack runs out:
const a = {n: 1}; a.self = a
const b = {n: 2}; b.self = b
merge(a, b) // RangeError: Maximum call stack size exceeded
A cycle on only one side is safe, because that branch has no counterpart to descend into and is carried over by reference:
const a = {n: 1}; a.self = a
merge(a, {m: 2}) // { n: 1, self: <the original a>, m: 2 }
merge({m: 2}, a) // { m: 2, n: 1, self: <the original a> }
Two cycles that sit at different keys are safe for the same reason — the recursion runs out of pairs before it runs out of stack. Only cycles that meet make it loop:
const a = {n: 1}; a.self = a
const b = {n: 2}; b.other = b
merge(a, b) // { n: 2, self: <a>, other: <b> }
If you need to merge such data, break the cycle first, or reach for a library that tracks visited pairs — lodash.merge does.
API
merge(...objects: any[]): any
Returns the merged value. With no arguments returns undefined; with a single argument returns it unchanged. Never mutates its arguments.
License
ISC Petro Borshchahivskyi