Open source · MIT

deepmerge-typed: a deep merge that keeps your TypeScript types exact

Nearly every project we ship has the same small function buried somewhere in it: take a defaults object, take whatever the caller actually passed, and hand back one settled configuration. It looks like a solved problem. In TypeScript it is not. The merge libraries we tried all returned a type that was subtly wrong, and a wrong type means the compiler quietly stops catching the mistakes you actually make.

So we wrote our own and published it. deepMerge(defaults, overrides) gives you back the exact type of defaults: every field still required, every union still narrowed the way you declared it. Overrides are checked against that type at every depth, so a key that does not exist, or a string where a number belongs, is a compile error instead of a setting that never applies.

No dependencies, 1.03 kB gzipped, ESM and CommonJS, MIT.

One Example, Start to Finish

Here's an HTTP client with five options and a caller who cares about two of them.

import { deepMerge } from 'deepmerge-typed';

interface ClientOptions {
    mode: 'compact' | 'full';
    retries: number;
    headers: { accept: string; encoding: string };
    hosts: string[];
    onRetry: () => void;
}

const defaults: ClientOptions = {
    mode: 'compact',
    retries: 3,
    headers: { accept: 'application/json', encoding: 'gzip' },
    hosts: ['localhost'],
    onRetry: () => undefined
};

const options = deepMerge(defaults, {
    mode: 'full',
    headers: { accept: 'text/html' }
});

options is a ClientOptions. Not a partial of one, not a union of two shapes. retries is still 3, headers.encoding is still 'gzip' even though the override only mentioned accept, and mode is still the two-literal union you declared rather than a widened string.

deepMerge takes any number of overrides, not just one, and applies them left to right, so later ones win:

deepMerge({ a: 1, b: 2, c: 3 }, { b: 20 }, { c: 30 });

gives { a: 1, b: 20, c: 30 }. An override that is undefined is skipped entirely, which removes a guard you have written a hundred times:

const options = deepMerge(defaults, condition ? overrides : undefined);

Wrong Overrides Do Not Compile

Each of these three lines is an error before you ever run it:

deepMerge(defaults, { verbose: true });
deepMerge(defaults, { mode: 'brief' });
deepMerge(defaults, { headers: { accept: 7 } });

ClientOptions has no verbose. 'brief' is not one of the two literals mode allows. And headers.accept is a string, two levels down. Hand any of those to a typical deep merge and it will happily build you an object carrying a setting that never applies, which is the kind of bug you find by re-reading the code rather than by running it.

What Gets Merged and What Gets Replaced

ValueWhat happens
Plain object on both sidesMerged key by key, as deep as the objects go
Plain object on one side onlyReplaces the other side
ArrayReplaces, and is copied
Date, RegExp, Map, Set, Promise, typed arrayReplaces, kept by reference
Class instance, functionReplaces, kept by reference
nullReplaces
undefinedNo override at all, so the default stays

The last row is a default rather than a law: createDeepMerge({ skipUndefined: false }) builds a merge where an explicit undefined comes through as a value of its own.

A plain object here means an object literal, a JSON.parse result, or an Object.create(null) object, including one built in another realm such as an iframe or a worker. Everything else is treated as a value rather than a branch, and values replace instead of merging.

Key order follows the defaults first, then any key only the overrides had. Symbol keys are carried over and merged like string keys.

Arrays

Arrays replace by default, because for a list of hosts or a list of plugins that is what you want nine times out of ten. For the tenth time, build a merge function with different rules:

import { createDeepMerge } from 'deepmerge-typed';

const mergeConfig = createDeepMerge({ arrays: 'concat' });
Mode[1, 2, 3] with [9][1, 2] with [2, 3]
'replace' (default)[9][2, 3]
'concat'[1, 2, 3, 9][1, 2, 2, 3]
'unique'[1, 2, 3, 9][1, 2, 3]
'byIndex'[9, 2, 3][2, 3]

You can also pass a function of your own. 'unique' drops repeats by identity, the way a Set does, so it dedupes primitives and shared references but not two objects that merely look alike. 'byIndex' merges the items sitting at the same index, the way lodash.merge does, and it is the only mode where an override item may be partial. In every other mode the item lands in the result whole, so the types ask for a whole item.

How It Compares to Other Deep Merges

deepmerge-typeddeepmerge-tsdeepmergelodash.merge
Result typed as the defaultsYesNoOnly if you annotate it yourselfWith @types/lodash.merge
Overrides checked at every depthYesNoNoNo
Arrays by defaultReplaceConcatenateConcatenateMerge by index
Result shares no subtree with the inputYesNoYesYes
Branches the overrides never mention are copiedYesNoYesYes
Drops a __proto__ key coming from JSON.parseYesNoYesYes
A cycle in the inputPoints at the resultPoints at the resultThrows a RangeErrorPoints at the input, not the result
Mutates its argumentsNoNoNoYes, the first one
Map and SetReplaced by referenceMergedReplacedReplaced
Dependencies0000

Measured against deepmerge-ts 8.0.2, deepmerge 4.3.1, and lodash.merge 4.6.2. None of the four pollute Object.prototype.

Replacing Map and Set rather than merging them is a decision, not an oversight. There is no one obvious answer for what merging two maps should do with a key both of them hold, and guessing silently is worse than replacing predictably. Merge them yourself, or through your own strategy, when your case has an obvious answer.

Safety

Prototype Pollution Never Gets Through

A __proto__ key is dropped from either side at any depth, so nothing you merge can reach Object.prototype. Keys that merely look dangerous stay ordinary keys: merge { toString: 'label' } and you get { toString: 'label' } back. __proto__ is the only key on Object.prototype carrying a setter, which is why it is the only one treated specially, and a test in the repository asserts exactly that.

Nothing Is Shared With the Input

Every plain object and array in the result is new, whichever side it came from, including branches the overrides never mentioned. Writing to the result cannot reach back into your defaults, and two results merged from the same defaults cannot disturb each other.

Cycles Come Out as Cycles

A value that points at itself, or at anything already being merged, comes out pointing at the matching part of the result. Not at a stale copy, and not into a stack overflow.

What Else Is in the Package

deepClone is the copier the merge is built on, exported on its own. It copies plain objects and arrays as deep as they go, keeps everything else by reference, preserves cycles, array holes, and array subclasses, and drops __proto__. Unlike structuredClone it keeps functions, class instances, and maps by reference instead of throwing or duplicating them, which is the right trade-off for configuration and the wrong one for sending data to a worker.

isPlainObject is the predicate the merge uses. It says yes to object literals, JSON.parse results, Object.create(null) objects, and literals from another realm; no to arrays, dates, maps, regular expressions, class instances, functions, and primitives.

DeepPartial<T> is worth importing even if you never merge anything. A hand-rolled deep partial usually wanders into arrays and makes their items optional, or maps over the methods of a Date. This one stops at arrays, tuples, functions, classes, and built-ins.

Size and Requirements

Each entry point stands on its own and sideEffects is false, so a bundler keeps only what you import.

You importRawGzipped
deepMerge3.60 kB1.03 kB
deepClone1.72 kB0.65 kB
isPlainObject0.30 kB0.19 kB
Everything5.36 kB1.45 kB

What ships is the build and its paperwork: eight files, no sources, no tests, no sourcemaps.

It runs on Node 18 or newer and in any modern browser. Every push installs the packed tarball on Node 18, 20, 22, and 24 and imports it from both ESM and CommonJS, so that range is measured rather than assumed. The types need TypeScript 5.4 or newer because they lean on NoInfer; without TypeScript the package still works, it just has less to say.

Why a Web Agency Publishes an npm Package

Most of what we write is client work, and nobody outside the project ever reads it. This one was worth breaking the habit for: the problem wasn't ours specifically, and the fix was small enough to give away.

It's also a fair sample of how we work. Merging a configuration object is exactly the sort of unglamorous detail that decides whether a codebase is still pleasant two years later. If yours has stopped being pleasant, whether it's an inherited project, a prototype that accidentally shipped, or a build nobody wants to touch, that's a good part of what we do.

Issues and pull requests are welcome on GitHub. If you use it and something is missing, tell us.

Questions About deepmerge-typed

Work With Us

Have a TypeScript codebase that needs this kind of attention?

Your data will not be shared with third parties and will be used solely for processing your request