When working with JavaScript, merging objects deeply is a common challenge, especially when handling configuration objects, state management, or aggregating nested data structures. In this article, I’ll walk through how I approached writing a recursive deep merge function, explaining my thought process and decisions along the way.
Understanding the Problem
JavaScript provides Object.assign()
and the spread operator (...
), but these methods only perform shallow merging. This means nested objects are overwritten, not merged. To handle deeply nested objects properly, a custom deep merge function is necessary.
Example Seed Data
To test our function, I used the following objects:
[
{
"name": "alice",
"age": 25,
"address": {
"city": "new york",
"zip": "10001"
},
"preferences": {
"theme": "dark",
"notifications": true
},
"scores": [
10,
20,
30
]
},
{
"age": 30,
"address": {
"city": "san francisco",
"country": "usa"
},
"preferences": {
"notifications": false,
"language": "english"
},
"scores": [
40,
50
],
"extra": "extra data"
},
{
"name": "bob",
"address": {
"zip": "94105"
},
"preferences": {
"theme": "light"
},
"scores": [
60
],
"nested": {
"level1": {
"level2": {
"value": 42
}
}
}
}
]
Expected Merge Behavior
- Primitive values (e.g.,
age
) should be overwritten. - Nested objects should be merged recursively.
- Arrays should be replaced.
- New properties should be included in the final output.
Implementing the Deep Merge Function
I implemented the merge function using recursion to handle nested structures:
import seeds from "./seed.json" with { type: "json" };
const ans = merge(...seeds);
console.log("ans", JSON.stringify(ans, null, 2));
/**
* @param {Record<string, unknown>} target
* @param {Record<string, unknown>[]} sources
*/
function merge(target, ...sources) {
if (!sources.length) return target;
const source = sources.shift();
for (const key in source) {
if (
!((o) => o && typeof o === "object" && !Array.isArray(o))(source[key])
) {
Object.assign(target, { [key]: source[key] });
continue;
}
if (!target[key]) Object.assign(target, { [key]: {} });
merge(target[key], source[key]);
}
return merge(target, ...sources);
}
Breakdown of the Approach
- Removing the First Item:
- The function
shift()
removes and returns the first item fromsources
, reducing the array size each recursion cycle.
- The function
- Handling Primitives:
- If a value is not an object (like numbers or strings), it is directly assigned to
target
usingObject.assign()
.
- If a value is not an object (like numbers or strings), it is directly assigned to
- Handling Nested Objects:
- If the key does not exist in
target
, an empty object{}
is assigned. - The function calls itself recursively to merge deeply nested structures.
- If the key does not exist in
Considerations and Future Enhancements
- Handling Arrays: Currently, arrays are overwritten instead of concatenated. A possible improvement is to introduce a merge strategy for arrays.
- Handling Edge Cases: Further improvements could include handling special cases like
null
,undefined
, or prototype pollution risks.
Conclusion
By implementing a recursive deep merge function, we can effectively merge complex nested objects without losing data. This approach is useful in various scenarios such as handling configurations, merging API responses, or managing deeply nested state in applications.