Deep Merge Plain Objects Safely

Merge nested configuration objects without mutation, cloning arrays and special values while blocking prototype-pollution keys.

JavascriptConfig Management

Code

snippet
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function isPlainObject(value) {
  if (value === null || typeof value !== "object") return false;
  const prototype = Object.getPrototypeOf(value);
  return prototype === Object.prototype || prototype === null;
}

function cloneValue(value) {
  if (Array.isArray(value)) return value.map(cloneValue);
  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof Map) {
    return new Map([...value].map(([key, item]) => [key, cloneValue(item)]));
  }
  if (value instanceof Set) {
    return new Set([...value].map(cloneValue));
  }
  if (isPlainObject(value)) {
    const output = {};
    for (const [key, item] of Object.entries(value)) {
      if (!DANGEROUS_KEYS.has(key)) output[key] = cloneValue(item);
    }
    return output;
  }
  return value;
}

function mergeInto(target, source) {
  for (const [key, value] of Object.entries(source)) {
    if (DANGEROUS_KEYS.has(key) || value === undefined) continue;
    if (isPlainObject(value)) {
      const base = isPlainObject(target[key]) ? target[key] : {};
      target[key] = mergeInto({ ...base }, value);
    } else {
      target[key] = cloneValue(value);
    }
  }
  return target;
}

export function deepMerge(...sources) {
  const result = {};
  for (const source of sources) {
    if (source === undefined || source === null) continue;
    if (!isPlainObject(source)) {
      throw new TypeError("deepMerge only accepts plain objects");
    }
    mergeInto(result, source);
  }
  return result;
}

const defaults = {
  server: { host: "localhost", port: 3000, tls: false },
  tags: ["api"],
  limits: { rpm: 60 },
};
const overrides = {
  server: { port: 8080, tls: true },
  tags: ["prod", "api"],
  extra: null,
};
const merged = deepMerge(defaults, overrides, { server: { host: "0.0.0.0" } });
console.log(JSON.stringify(merged));
console.log(defaults.server.port, defaults.tags.join(","));

const polluted = deepMerge(
  defaults,
  JSON.parse('{"__proto__":{"polluted":true}}'),
);
console.log(polluted.polluted === undefined, {}.polluted === undefined);