Implementing a Deep Merge Function in JavaScript

Created Updated
3 min read 488 words

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

  1. Primitive values (e.g., age) should be overwritten.
  2. Nested objects should be merged recursively.
  3. Arrays should be replaced.
  4. 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

  1. Removing the First Item:
    • The function shift() removes and returns the first item from sources, reducing the array size each recursion cycle.
  2. Handling Primitives:
    • If a value is not an object (like numbers or strings), it is directly assigned to target using Object.assign().
  3. 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.

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.

Links

Add comment below...

Please refresh the page if commenting system is not visible.
Further reading