npm.io
1.1.0 • Published 15h ago

ya-merge

Licence
ISC
Version
1.1.0
Deps
0
Size
11 kB
Vulns
0
Weekly
0

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 nullb 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

Keywords