Merging Objects Without Side Effects

Posted by

A practical guide to safe, immutable merges in JavaScript/TypeScript — covering shallow vs deep, conflict rules, arrays, performance, and battle-tested utilities.

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

Your email address will not be published. Required fields are marked *