A generic solution using ES6 Proxies
The following is a generic solution leveraging ES6 Proxies (introduced in the ECMAScript 2015 Language Specification (ES6)) to perform a customizable deep merge and concatenation of any normal objects it is passed. This function allows for easy customization of the merging process for each type of input value encountered and should be able to handle any type of input value, so long as the objects it is passed would normally work with Object.assign()
.
Explanation
Getting types accurately
It is important for handling purposes to easily and accurately identify the type of the input values. To do this the function leverages Object.prototype.toString
which (when called with against an arbitrary value) will return a string along the lines of [object String]
or [object Object]
, etc. From there we can strip out the unnecessary bits to end up with a string like String
or Object
, etc.
const gettype = e => Object.prototype.toString.call(e).replace(/.*\b(\w+)./, '$1');
Handling the handlers
We need to streamline handling of different data types. You could use messy, hard-coded switch statements, but I prefer something a bit more fluid. I like using an object of functions. For each type we want to handle, we simply define a property name that directly matches the output of the gettype
function defined above. Then when needed, we can look up that function based on the corresponding type value.
In this example, I create a default set of handlers, which is then overwritten by the supplied handlers provided in the first argument of the function.
Object.assign(h, {
"Array": (a, b) => [...a, ...b],
"Object": (a, b) => deepmerge(h, a, b),
"Set": (a, b) => new Set([...a, ...b]),
"Map": (a, b) => {
const o = new Map([...a]);
for(let [k,v] of [...b]) {
const vt = gettype(v);
o.set(k, a.has(k) && vt in h ? h[vt](a.get(k), v) : v);
}
return o;
}
}, h);
Intercepting the set operation using proxies
Now we will get into the proxy part. First define an "out" object. This object is what will eventually be returned by this function, and will serve as the target for the proxy.
const o = {};
Next create a proxy for the "out" object with a set trap. The set trap passes the original and new values on to the appropriate set handlers when a set operation happens. If there is no appropriate set handler, it overwrites the old value with the new value. The determined value is applied to the "out" object.
const p = new Proxy(o, {
set: (o, k, b) => {
const a = o[k];
const at = gettype(a);
return (o[k] = (at in h && k in o ? h[at](a, b) : b), true);
}
});
Next use Object.assign
to merge the objects into the proxy, which triggers the set trap defined above for each set operation.
Object.assign(p, ...args);
Finally return the "out" object.
return o;
Full definition
const deepmerge = (h, ...args) => {
const gettype = e => Object.prototype.toString.call(e).replace(/.*\b(\w+)./, '$1');
const o = {};
Object.assign(h, {
"Array": (a, b) => [...a, ...b],
"Object": (a, b) => deepmerge(h, a, b),
"Set": (a, b) => new Set([...a, ...b]),
"Map": (a, b) => {
const o = new Map([...a]);
for(let [k,v] of [...b]) {
const vt = gettype(v);
o.set(k, a.has(k) && vt in h ? h[vt](a.get(k), v) : v);
}
return o;
}
}, h);
const p = new Proxy(o, {
set: (o, k, b) => {
const a = o[k];
const at = gettype(a);
return (o[k] = (at in h && k in o ? h[at](a, b) : b), true);
}
});
Object.assign(p, ...args);
return o;
};
Usage
deepmerge(handlers, ...sources)
Handlers
The handler object. The handler object can be used to override the default handler for a given type, or provide handlers for types that are not handled by default.
Example:
{
"String": (a, b) => a + b,
"Number": (a, b) => a + b,
}
- The name of each property should be the name of the type being handled, with the first letter capitalized.
- The value of each property should be a function which accepts two arguments and returns the result of merging those arguments.
Sources
The objects being merged.
- Each object being merged must be a plain object (no maps, sets, or arrays, etc).
- Each object may contain values of any type.
Examples
The following snippet shows two examples:
- The first uses just the default handlers
- The second uses a few custom handlers to demonstrate customization of the merging process. Note the difference in the two resulting objects.
const objects = [
{
string: "hello",
number: 1,
boolean: true,
array: [1,2,3],
object: { a: 1, b: 2 },
map: new Map([[1,2],[2,3],[4,2],[undefined, undefined],[null, null]]),
set: new Set([1,2,3]),
null: null,
undefined: undefined
},
{
string: " world",
number: 2,
boolean: false,
array: [4,5,6],
object: { a: 2, b: 1 },
map: new Map([[1,1],[2,0],[3,1],[undefined, null],[null, undefined]]),
set: new Set([4,5,6]),
null: undefined,
undefined: null
}
];
console.log(deepmerge({}, ...objects));
console.log(deepmerge({
"String": (a, b) => a + b,
"Number": (a, b) => a + b,
}, ...objects));
<script src="https://cdn.rawgit.com/Tiny-Giant/43cc03adf3cdc84ff935655cbebbe585/raw/754070ca8858efeff5a2c3b8bad6475842565798/deepmerge.js"></script><link rel="stylesheet" href="https://cdn.rawgit.com/Tiny-Giant/f2a53f469863baadf1b4ad48a4b4ea39/raw/b0ede74f374199abe9324334d1c0ef088a850415/deepmerge.css" type="text/css">