A practical guide to safe, immutable merges in JavaScript/TypeScript — covering shallow vs deep, conflict rules, arrays, performance, and battle-tested utilities.
Introduction
Merging objects seems trivial until a subtle mutation takes down your UI or corrupts cached state. You copy a config, tweak a nested key, and suddenly the original changes too. That’s the classic side-effects trap.
This post shows how to merge objects safely and immutably, when to use shallow vs deep strategies, how to handle arrays, Dates, Maps/Sets, and how to avoid prototype pollution. You’ll get copy-paste utilities for real projects (React, Node, Next.js) and TypeScript-friendly patterns.
1) Side Effects 101: What Goes Wrong
const base = { ui: { theme: 'light' } };
const merged = base; // ❌ reference copy
merged.ui.theme = 'dark';
console.log(base.ui.theme); // "dark" (oops)
- Assigning references or mutating in place causes side effects.
- Even “safe-looking” copies like
{...obj}
are shallow—nested objects remain shared.
Rule: If you’ll mutate the result (or keep it long-lived), create a new object and understand how deep you need to clone/merge.
2) Shallow Merge: Clean & Fast (When It’s Enough)
A) Object Spread (idiomatic)
const merged = { ...a, ...b }; // b overrides a (left→right)
B) Object.assign
const merged = Object.assign({}, a, b);
Use when
- You only care about top-level keys.
- Nested objects should be replaced, not merged.
Gotcha: Nested objects are shared:
const m = { ...a, ...b };
m.nested.value = 1; // also affects a/b if nested is shared
3) Deep Merge: Combine Nested Structures
For nested configs, feature flags, localization bundles, etc., you often want a recursive merge.
Deep merge semantics (decide up front)
- Objects: merge key-by-key.
- Arrays: replace or concat or dedupe? (pick a policy)
- Primitives: last-in wins (override).
- Special types: how to treat Date, Map, Set, RegExp, TypedArrays?
4) Real-World Merge Policies (Pick One)

Tip: Document the array policy in your code comment. Most merge bugs come from unspoken array expectations.
5) Production-Ready Deep Merge (Copy–Paste)
A) “Replace arrays” policy (safest default)
export function deepMerge<T extends Record<string, any>, U extends Record<string, any>>(
target: T,
source: U
): T & U {
if (target === source) return target as T & U;
const out: any = Array.isArray(target) || Array.isArray(source) ? [] : { ...target };
for (const key of Object.keys(source)) {
const a = (target as any)[key];
const b = (source as any)[key];
if (isPlainObject(a) && isPlainObject(b)) {
out[key] = deepMerge(a, b);
} else if (Array.isArray(b)) {
// Replace arrays by default
out[key] = b.slice();
} else if (b instanceof Date) {
out[key] = new Date(b.getTime());
} else if (b instanceof RegExp) {
out[key] = new RegExp(b.source, b.flags);
out[key].lastIndex = b.lastIndex;
} else if (b instanceof Map) {
out[key] = new Map(b); // shallow clone entries
} else if (b instanceof Set) {
out[key] = new Set(b); // shallow clone values
} else if (isTypedArray(b)) {
// @ts-ignore
out[key] = new (b.constructor as any)(b);
} else if (b !== undefined) {
out[key] = b;
}
}
return out;
}
function isPlainObject(v: any): v is Record<string, any> {
return Object.prototype.toString.call(v) === '[object Object]';
}
function isTypedArray(v: any) {
return ArrayBuffer.isView(v) && !(v instanceof DataView);
}
B) “Concat arrays” policy (opt-in)
Replace the array branch with:
else if (Array.isArray(a) && Array.isArray(b)) {
out[key] = a.concat(b); // or unique by value using Set
} else if (Array.isArray(b)) {
out[key] = b.slice();
}
C) “Merge arrays by key” policy
type KeyFn<T> = (x: T) => string | number;
export function mergeArraysByKey<T>(a: T[], b: T[], key: KeyFn<T>): T[] {
const map = new Map(a.map(x => [key(x), x]));
for (const item of b) map.set(key(item), item); // b wins
return [...map.values()];
}
6) Safer Defaults With structuredClone
+ Merge
You can clone a source before merging to prevent shared references:
const safeSource = typeof structuredClone === 'function'
? structuredClone(source)
: JSON.parse(JSON.stringify(source)); // JSON-safe only
const merged = deepMerge(base, safeSource);
Note: JSON fallback drops Dates/Maps/Sets/undefined/BigInt and fails on cycles — use only for plain data.
7) React/Redux Patterns (Immutable by Design)
A) Override leaf config
const next = { ...state, feature: { ...state.feature, enabled: true } };
B) Merge server settings into local defaults
const config = deepMerge(DEFAULTS, fromServer);
C) Avoid re-render storms
Memoize merges if inputs are stable:
const config = useMemo(() => deepMerge(defaults, overrides), [defaults, overrides]);
8) TypeScript Tips You’ll Appreciate
A) Preserve shape on merge
function assign<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b } as T & U;
}
B) Partial overrides with safety
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
function applyOverrides<T extends object>(base: T, partial: DeepPartial<T>): T {
return deepMerge(base, partial as T);
}
C) Strict keys: flag unknown overrides
Add a dev-only guard:
function assertKnownKeys<T extends object>(base: T, override: any, path = '') {
for (const k of Object.keys(override)) {
if (!(k in base)) throw new Error(`Unknown key "${path}${k}" in overrides`);
const b = (base as any)[k], o = override[k];
if (isPlainObject(b) && isPlainObject(o)) assertKnownKeys(b, o, `${path}${k}.`);
}
}
9) Security: Prototype Pollution & Guard Rails
Prototype pollution occurs when user input injects __proto__
, prototype
, or constructor
keys into your merge target.
Mitigations
- Reject dangerous keys during merge:
const BLOCKED = new Set(['__proto__', 'prototype', 'constructor']);
function safeKey(key: string | symbol) {
return typeof key === 'string' ? !BLOCKED.has(key) : true;
}
// In deepMerge loop, guard:
for (const key of Object.keys(source)) {
if (!safeKey(key)) continue; // skip dangerous keys
// merge…
}
- Only deep-merge trusted objects. Treat API payloads as untrusted — validate first (Zod/Yup).
10) Behavior of Built-ins (Know Before You Pick)
{...a, ...b}
&Object.assign({}, a, b)
- Shallow, enumerable own keys, left→right override, ignores prototype.
- Skips Symbols? Spread copies enumerable symbols;
JSON
loses them. structuredClone(obj)
- Deep clone with strong type support (Date, Map, Set, TypedArrays, cycles).
- Loses custom prototypes (plain objects). Not a merge — use it to clone inputs first.
- Libraries (
lodash.merge
,deepmerge
) - Handle deep merge with options; array policy varies.
- Verify how they treat special types and pollution protections.
11) Performance Notes (Pragmatic)
- Prefer shallow merges on hot paths (render loops, tight handlers).
- For deep merges, keep objects normalized (by id) to reduce merge scope.
- Memoize derived configs; don’t deep-merge every render.
- Avoid huge object graphs; split state by feature/module.
12) Practical Recipes You’ll Reuse
A) Merge defaults + env + runtime overrides
const cfg = deepMerge(
DEFAULTS,
ENV_OVERRIDES,
RUNTIME_OVERRIDES
);
B) Merge headers (case-insensitive keys)
function mergeHeaders(a: Record<string,string>, b: Record<string,string>) {
const lower = (o: any) => Object.fromEntries(Object.entries(o).map(([k,v]) => [k.toLowerCase(), v]));
return { ...lower(a), ...lower(b) }; // b wins
}
C) Immutable “omit” while merging
const { password, ...publicUser } = deepMerge(user, patch);
D) Merge arrays of objects by id
const merged = mergeArraysByKey(oldList, newList, x => x.id);
13) Common Mistakes (and Fixes)
- Assuming spread is deep. It’s shallow. Use deep merge only when necessary.
- Silent array concatenation. Teams expect replace; you concat and duplicate entries → define policy.
- Merging untrusted payloads. Validate first; guard against
__proto__
. - Ref churn in React. Merging fresh objects every render can bust memoization — move merges up the tree or memoize.
- Clobbering special types. JSON or naive merges turn Dates into strings, Maps/Sets into plain objects — use
structuredClone
or typed branches.
Conclusion
Merging without side effects comes down to clarity and intent:
- Use shallow merges for speed and predictability when nested replacement is fine.
- Use a deep merge when you truly need recursive combination — and document your array policy.
- Protect against prototype pollution and special type pitfalls.
- Wrap your approach in small utilities so the whole team merges the same way.
Pro Tip: Treat merges like contracts. Write down the rules (override order, arrays, special types), enforce them in a single helper, and reuse it everywhere.
Call to Action (CTA)
What merge policy does your team use for arrays — replace, concat, or merge-by-key?
Share your approach (and any hard-won lessons) in the comments. If this helped, bookmark it and share with your team for the next refactor.
Appendix: Tiny Utility Pack (Drop-in)
// 1) Shallow merge (typed)
export const mergeShallow = <A extends object, B extends object>(a: A, b: B) =>
({ ...a, ...b }) as A & B;
// 2) Deep merge (replace arrays), pollution-safe
const BLOCKED = new Set(['__proto__', 'prototype', 'constructor']);
const isPlainObject = (v: any) => Object.prototype.toString.call(v) === '[object Object]';
const isTypedArray = (v: any) => ArrayBuffer.isView(v) && !(v instanceof DataView);
export function deepMergeSafe<T extends Record<string, any>>(...objects: T[]): T {
return objects.reduce((acc, src) => {
if (!src || acc === src) return acc;
const out: any = acc;
for (const key of Object.keys(src)) {
if (BLOCKED.has(key)) continue;
const a = out[key];
const b = (src as any)[key];
if (isPlainObject(a) && isPlainObject(b)) out[key] = deepMergeSafe(a, b);
else if (Array.isArray(b)) out[key] = b.slice();
else if (b instanceof Date) out[key] = new Date(b.getTime());
else if (b instanceof RegExp) { const r = new RegExp(b.source, b.flags); r.lastIndex = b.lastIndex; out[key] = r; }
else if (b instanceof Map) out[key] = new Map(b);
else if (b instanceof Set) out[key] = new Set(b);
else if (isTypedArray(b)) { /* @ts-ignore */ out[key] = new (b.constructor as any)(b); }
else if (b !== undefined) out[key] = b;
}
return out;
}, {} as T);
}
// 3) Merge arrays of objects by key (b wins)
export function mergeArraysByKey<T>(a: T[], b: T[], key: (x: T) => string | number): T[] {
const map = new Map(a.map(x => [key(x), x]));
for (const item of b) map.set(key(item), item);
return [...map.values()];
}
Leave a Reply