JSON Deep Merge: From Object.assign to Recursive Merge Algorithms
JSON Deep Merge: From Object.assign to Recursive Merge Algorithms#
When merging config files, I discovered that Object.assign and the spread operator ... only perform shallow copies. Nested properties get replaced instead of merged. This led me to rethink the correct approach to JSON merging.
The Shallow Merge Problem#
Here’s a common pitfall:
const defaults = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
headers: { 'Content-Type': 'application/json' }
},
retry: 3
}
const userConfig = {
api: {
baseUrl: 'https://custom.api.com',
headers: { 'Authorization': 'Bearer token' }
}
}
// Shallow merge
const merged = { ...defaults, ...userConfig }
// Result:
// {
// api: { baseUrl: 'https://custom.api.com', headers: { Authorization: 'Bearer token' } },
// retry: 3
// }
The problem is obvious: the api object gets completely replaced. timeout is lost, and Content-Type is gone too. Not what we wanted.
The Deep Merge Algorithm#
Deep merge requires recursive handling of nested objects:
function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>, mergeArrays = false): T {
// Array handling strategy
if (Array.isArray(target) && Array.isArray(source)) {
return mergeArrays ? [...target, ...source] : source
}
// Recursive object merge
if (isObject(target) && isObject(source)) {
const result = { ...target }
for (const key in source) {
if (source.hasOwnProperty(key)) {
result[key] = key in result
? deepMerge(result[key], source[key], mergeArrays)
: source[key]
}
}
return result
}
// Primitive types: direct replacement
return source
}
function isObject(val: any): val is Record<string, any> {
return val !== null && typeof val === 'object' && !Array.isArray(val)
}
Using this algorithm with our config example:
const merged = deepMerge(defaults, userConfig)
// Result:
// {
// api: {
// baseUrl: 'https://custom.api.com',
// timeout: 5000, // Preserved default value
// headers: {
// 'Content-Type': 'application/json', // Merged headers
// 'Authorization': 'Bearer token'
// }
// },
// retry: 3
// }
This is the expected behavior.
Array Merge Strategies#
Array handling is a design decision. Two common strategies:
1. Replace Strategy (Default)#
const target = { tags: ['javascript', 'typescript'] }
const source = { tags: ['python', 'rust'] }
deepMerge(target, source)
// { tags: ['python', 'rust'] } // Source replaces target
2. Concatenate Strategy#
deepMerge(target, source, true) // mergeArrays = true
// { tags: ['javascript', 'typescript', 'python', 'rust'] }
The choice depends on your use case. Config files typically use replace, while tags and lists might need concatenation.
Edge Cases#
1. null and undefined#
// null is a valid value, undefined means "not set"
deepMerge({ a: 1, b: null }, { b: undefined })
// { a: 1, b: null } // undefined doesn't override
deepMerge({ a: 1 }, { a: null })
// { a: null } // null overrides normally
2. Type Conflicts#
// Object replaced by primitive
deepMerge({ config: { nested: true } }, { config: 'simple' })
// { config: 'simple' }
// Primitive replaced by object
deepMerge({ name: 'test' }, { name: { first: 'John' } })
// { name: { first: 'John' } }
When types conflict, source directly replaces target. This matches the “later config overrides earlier” intuition.
3. Circular References#
const obj = { a: 1 }
obj.self = obj
deepMerge(obj, { b: 2 }) // Infinite recursion → stack overflow
Detect circular references:
function deepMergeSafe<T>(
target: T,
source: any,
seen = new WeakSet()
): T {
if (seen.has(target)) return target
if (isObject(target)) seen.add(target)
// ... rest of the logic
}
4. Special Objects#
Date, RegExp, Map, Set, etc.:
function isPlainObject(val: any): boolean {
if (val === null || typeof val !== 'object') return false
const proto = Object.getPrototypeOf(val)
return proto === null || proto === Object.prototype
}
// Only plain objects get recursive merge
if (isPlainObject(target) && isPlainObject(source)) {
// Recursive merge
}
Performance Optimization#
1. Avoid Unnecessary Copies#
// If source has no nested objects, return early
if (Object.values(source).every(v => !isObject(v))) {
return { ...target, ...source }
}
2. Use for…in Instead of Object.keys#
// Object.keys creates an array
for (const key of Object.keys(source)) { }
// for...in is more efficient
for (const key in source) {
if (source.hasOwnProperty(key)) { }
}
3. Incremental Merge Cache#
For frequent update scenarios:
class MergeCache {
private cache = new Map<string, any>()
merge(base: any, updates: any, id: string) {
const cached = this.cache.get(id)
if (cached && shallowEqual(cached.updates, updates)) {
return cached.result
}
const result = deepMerge(base, updates)
this.cache.set(id, { updates, result })
return result
}
}
Real-World Use Cases#
1. Config File Merging#
// Default config + environment config + user config
const config = deepMerge(
defaultConfig,
envConfig,
userConfig
)
2. API Request Parameters#
// Default params + request params
const params = deepMerge(
{ headers: { 'Content-Type': 'application/json' } },
customParams
)
3. State Management#
// Redux reducer
function reducer(state, action) {
return deepMerge(state, action.payload)
}
Online Tool#
Based on this algorithm, I built: JSON Merge Tool
Features:
- Merge multiple JSONs at once
- Configurable array merge strategy
- Real-time preview
- Error location hints
The core code is under 50 lines, but handles all edge cases. That’s the thing with building tools—the details behind simple functionality are what matter.
Related: JSON Diff | JSON Formatter