In JavaScript and TypeScript application architecture, combining configurations, managing nested state states, or applying user overrides to default settings are standard requirements.
However, many developers still fall into the trap of using shallow copy mechanisms like Object.assign() or the object spread operator (...). When applied to nested objects, these approaches can lead to silent data loss, state mutations, and hard-to-track runtime bugs.
In this technical breakdown, we will analyze why shallow merging fails on nested structures, explore the mechanics of a recursive deep merge algorithm, and write a type-safe, non-any deep merge utility in TypeScript.
1. The Fallacy of Shallow Merging
Shallow copying only copies the top-level properties of an object. If a property is a reference type (like a nested object or array), only the reference pointer is copied—not the actual nested data.
Consider this common configuration scenario:
const defaultSettings = {
theme: 'light',
api: {
endpoint: 'https://api.example.com',
timeout: 5000
}
};
const userOverrides = {
api: {
timeout: 10000
}
};
// The Shallow Merge Trap
const mergedSettings = { ...defaultSettings, ...userOverrides };
console.log(mergedSettings);
/*
Output:
{
theme: 'light',
api: {
timeout: 10000
}
}
*/
Why did this fail?
Because the spread operator performed a shallow assignment, the nested api property on userOverrides completely overwrote the original api object on defaultSettings. As a result, the critical endpoint property was completely lost.
To combine nested properties rather than overwriting them, we must perform a Recursive Deep Merge.
2. The Mechanics of a Recursive Deep Merge Algorithm
A true deep merge algorithm must recursively traverse the object trees. If both objects share a key, the algorithm must evaluate the types of their respective values:
- Both values are objects: Recursively call the merge function to combine their sub-properties.
- Both values are arrays: Decide whether to concatenate them, keep unique elements, or overwrite with the new array.
- Value types differ (or are primitives): Resolve the conflict by either keeping the original value or overwriting it based on a designated collision policy.
3. Writing a Type-Safe Deep Merge Utility in TypeScript
To ensure strict compiler compliance under modern TypeScript standards, we must avoid the forbidden any type and build a robust, structurally typed merging engine.
Here is the clean, type-safe implementation:
/**
* Safe helper checking if an item is a non-null, non-array object
*/
function isObject(item: unknown): boolean {
return typeof item === 'object' && item !== null && !Array.isArray(item);
}
/**
* Recursively deep-merges two object trees securely
* @param a The base target object
* @param b The incoming override object
* @param arrayStrategy How to handle array collisions ('concat' | 'unique' | 'replace')
* @param conflictStrategy How to resolve primitive conflicts ('overwrite-b' | 'keep-a')
*/
function deepMerge(
a: Record<string, unknown>,
b: Record<string, unknown>,
arrayStrategy: 'concat' | 'unique' | 'replace' = 'concat',
conflictStrategy: 'overwrite-b' | 'keep-a' = 'overwrite-b'
): Record<string, unknown> {
const result = { ...a };
for (const key of Object.keys(b)) {
const valA = a[key];
const valB = b[key];
if (isObject(valA) && isObject(valB)) {
// Both are objects: recurse deeper
result[key] = deepMerge(
valA as Record<string, unknown>,
valB as Record<string, unknown>,
arrayStrategy,
conflictStrategy
);
} else if (Array.isArray(valA) && Array.isArray(valB)) {
// Both are arrays: apply array merging strategy
if (arrayStrategy === 'concat') {
result[key] = [...valA, ...valB];
} else if (arrayStrategy === 'unique') {
result[key] = Array.from(new Set([...valA, ...valB]));
} else {
result[key] = [...valB]; // replace
}
} else {
// Primitive or mismatched types: apply conflict resolution
if (valA !== undefined) {
result[key] = conflictStrategy === 'keep-a' ? valA : valB;
} else {
result[key] = valB;
}
}
}
return result;
}
Why this structure is secure:
-
Strong Type Assertions: It uses structural record indexing (
Record<string, unknown>) to protect the compiler against type-erasure. - Predictable Collisions: By separating array strategies and value conflicts, the algorithm avoids random reference changes or unexpected state mutations.
Interactive Playground
If you need to merge complex settings side-by-side, analyze conflicts, customize your array concatenation behaviors, and inspect the resulting JSON securely inside your local browser memory:
👉 JS Object Merging Utility on Kandz.me
How do you manage nested configurations in your frameworks? Let's discuss in the comments!
Top comments (0)