Deep Merge Two Objects
Layering a defaults object under a user override is a daily need for config files, theme tokens, and React props, but `Object.assign` and spread only do shallow merges. This snippet walks from a recursive deep merge that follows nested plain objects, to an array-strategy variant that lets the caller pick concat vs replace, to a variadic version that folds an arbitrary number of layers from defaults to overrides.
490 views
7
function isPlainObject(v) {
return v !== null && typeof v === 'object' && (v.constructor === Object || v.constructor === undefined);
}
function deepMerge(target, source) {
if (!isPlainObject(target) || !isPlainObject(source)) return source;
const result = { ...target };
for (const key of Object.keys(source)) {
result[key] = isPlainObject(target[key]) && isPlainObject(source[key])
? deepMerge(target[key], source[key])
: source[key];
}
return result;
}
const defaults = { theme: { color: 'red', size: 14 }, debug: false };
const overrides = { theme: { color: 'blue' }, debug: true };
console.log(deepMerge(defaults, overrides));
// { theme: { color: 'blue', size: 14 }, debug: true }Deep merge recurses only into plain objects so it never accidentally walks into a Date, a Map, or a class instance, all of which a naive merger would corrupt. The isPlainObject guard checks the constructor so Object.create(null) (a common dictionary pattern) and literal objects both qualify. Returning a fresh object via spread keeps the input immutable, which matters when the same defaults object is passed to many consumers. This is the version to default to for config and theme overrides.
function isPlain(v) {
return v !== null && typeof v === 'object' && (v.constructor === Object || v.constructor === undefined);
}
function deepMergeWith(target, source, { arrayStrategy = 'replace' } = {}) {
if (Array.isArray(target) && Array.isArray(source)) {
return arrayStrategy === 'concat' ? [...target, ...source] : [...source];
}
if (!isPlain(target) || !isPlain(source)) return source;
const result = { ...target };
for (const key of Object.keys(source)) {
result[key] = (isPlain(target[key]) && isPlain(source[key])) ||
(Array.isArray(target[key]) && Array.isArray(source[key]))
? deepMergeWith(target[key], source[key], { arrayStrategy })
: source[key];
}
return result;
}
const base = { plugins: ['core', 'auth'], options: { retries: 3 } };
const extra = { plugins: ['logging'], options: { retries: 5 } };
console.log(deepMergeWith(base, extra));
// { plugins: ['logging'], options: { retries: 5 } }
console.log(deepMergeWith(base, extra, { arrayStrategy: 'concat' }));
// { plugins: ['core', 'auth', 'logging'], options: { retries: 5 } }Arrays are the part of deep merge that has no "obviously right" answer: should ['core'] plus ['logging'] become ['logging'] (replace) or ['core', 'logging'] (concat)? Production tools split on this: lodash concatenates, while most config loaders replace. Exposing the strategy as an option pushes the decision back to the caller, which is the only correct API choice for a shared utility. Watch for nested arrays of objects: this version treats them as opaque, so consider a keyBy strategy if you need element-level merging.
function isPo(v) {
return v !== null && typeof v === 'object' && (v.constructor === Object || v.constructor === undefined);
}
function mergeTwo(target, source) {
if (!isPo(target) || !isPo(source)) return source;
const result = { ...target };
for (const key of Object.keys(source)) {
result[key] = isPo(target[key]) && isPo(source[key])
? mergeTwo(target[key], source[key])
: source[key];
}
return result;
}
function deepMergeAll(...layers) {
return layers.reduce((acc, layer) => mergeTwo(acc, layer), {});
}
const defaultLayer = { theme: { color: 'red', size: 14 }, debug: false };
const envConfig = { theme: { size: 16 } };
const userConfig = { debug: true };
console.log(deepMergeAll(defaultLayer, envConfig, userConfig));
// { theme: { color: 'red', size: 16 }, debug: true }Real apps stack many config layers: built-in defaults, environment file, runtime flags, and a user override. Folding them through reduce with a starting {} gives you a single readable expression where the rightmost argument wins, mirroring how Object.assign(target, ...sources) already works for shallow merges. This pairs well with React's component props and Vitest's config loaders. If the layer count is huge, consider mutating acc instead of spreading on every step to drop the intermediate allocations.
