Skip to content

deepMerge function ​

Applies a partial patch to nested settings, such as saved preferences over the defaults: plain objects are merged at every depth, other values replace, undefined keeps. A __proto__ key stays a plain property.

ts
import { deepMerge } from 'utils';

Signature ​

ts
export function deepMerge<T extends object>(base: T, patch: DeepPartial<NoInfer<T>>): T

Parameters ​

NameDescription
TThe type of the settings.
baseThe complete object, such as the defaults.
patchThe values to change.

Returns ​

A new object that shares the unchanged branches with base.

Example ​

ts
deepMerge({ grid: { step: 10, isVisible: true } }, { grid: { isVisible: false } });
// { grid: { step: 10, isVisible: false } }

Tests ​

Line coverage: 100 %.

  • ✅ merges nested objects and replaces other values
  • ✅ keeps the values that the patch leaves undefined
  • ✅ shares unchanged branches and modifies no argument
  • ✅ adds keys missing from the base
  • ✅ keeps proto from JSON as an ordinary property

Source ​

src/object/deep-merge.ts (on GitHub)

Uses ​

  • isRecord: Checks whether a value is a plain object: an object literal or Object.create(null).
  • DeepPartial: Makes every property optional, at every depth: a patch of a nested settings object, read-only since a patch is only read.

Files it needs ​

src/object/deep-merge.ts imports, directly or not:

Source code
ts
import { isRecord } from '../guard';
import type { DeepPartial } from '../types';

function mergeValues(base: unknown, patch: unknown): unknown {
  if (patch === undefined) {
    return base;
  }
  if (!isRecord(base) || !isRecord(patch)) {
    return patch;
  }
  return Object.fromEntries([
    ...Object.entries(base),
    ...Object.entries(patch).map(([key, value]) => [
      key,
      mergeValues(Object.hasOwn(base, key) ? base[key] : undefined, value),
    ]),
  ]);
}

/**
 * Applies a partial patch to nested settings, such as saved preferences over the defaults: plain objects are
 * merged at every depth, other values replace, `undefined` keeps. A `__proto__` key stays a plain property.
 *
 * @template T - The type of the settings.
 * @param base - The complete object, such as the defaults.
 * @param patch - The values to change.
 * @returns A new object that shares the unchanged branches with `base`.
 * @example
 * deepMerge({ grid: { step: 10, isVisible: true } }, { grid: { isVisible: false } });
 * // { grid: { step: 10, isVisible: false } }
 */
export function deepMerge<T extends object>(base: T, patch: DeepPartial<NoInfer<T>>): T {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- A DeepPartial<T> merged into a T is a T.
  return mergeValues(base, patch) as T;
}
Test file
ts
import { assert, isRecord } from '../guard';
import { deepMerge } from './deep-merge';

describe(deepMerge, () => {
  const defaults = { grid: { step: 10, isVisible: true }, series: ['a', 'b'], title: 'Speed' };

  it('merges nested objects and replaces other values', () => {
    expect(deepMerge(defaults, { grid: { isVisible: false }, series: ['c'] })).toStrictEqual({
      grid: { step: 10, isVisible: false },
      series: ['c'],
      title: 'Speed',
    });
  });

  it('keeps the values that the patch leaves undefined', () => {
    const base: { title?: string | undefined; grid?: { step: number } | undefined } = { title: 'a', grid: { step: 1 } };
    expect(deepMerge(base, { title: undefined, grid: undefined })).toStrictEqual(base);
  });

  it('shares unchanged branches and modifies no argument', () => {
    const patch = { title: 'Heading' };
    const result = deepMerge(defaults, patch);
    expect(result.grid).toBe(defaults.grid);
    expect(defaults.title).toBe('Speed');
    expect(patch).toStrictEqual({ title: 'Heading' });
  });

  it('adds keys missing from the base', () => {
    expect(deepMerge<{ a?: { b?: number } }>({}, { a: { b: 1 } })).toStrictEqual({ a: { b: 1 } });
  });

  it('keeps __proto__ from JSON as an ordinary property', () => {
    const patch: unknown = JSON.parse('{ "__proto__": { "isAdmin": true } }');
    assert(isRecord(patch), 'Assertion failed');
    const result = deepMerge<Record<string, unknown>>({}, patch);
    expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
    expect(Object.hasOwn(result, '__proto__')).toBe(true);
    expect(Object.prototype).not.toHaveProperty('isAdmin');
  });
});

Keywords: deep, merge, objects, function

Generated from the JSDoc of the sources.