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
| Value | What happens |
|---|---|
| Plain object on both sides | Merged key by key, as deep as the objects go |
| Plain object on one side only | Replaces the other side |
| Array | Replaces, and is copied |
Date, RegExp, Map, Set, Promise, typed array | Replaces, kept by reference |
| Class instance, function | Replaces, kept by reference |
null | Replaces |
undefined | No 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-typed | deepmerge-ts | deepmerge | lodash.merge | |
|---|---|---|---|---|
| Result typed as the defaults | Yes | No | Only if you annotate it yourself | With @types/lodash.merge |
| Overrides checked at every depth | Yes | No | No | No |
| Arrays by default | Replace | Concatenate | Concatenate | Merge by index |
| Result shares no subtree with the input | Yes | No | Yes | Yes |
| Branches the overrides never mention are copied | Yes | No | Yes | Yes |
Drops a __proto__ key coming from JSON.parse | Yes | No | Yes | Yes |
| A cycle in the input | Points at the result | Points at the result | Throws a RangeError | Points at the input, not the result |
| Mutates its arguments | No | No | No | Yes, the first one |
Map and Set | Replaced by reference | Merged | Replaced | Replaced |
| Dependencies | 0 | 0 | 0 | 0 |
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 import | Raw | Gzipped |
|---|---|---|
deepMerge | 3.60 kB | 1.03 kB |
deepClone | 1.72 kB | 0.65 kB |
isPlainObject | 0.30 kB | 0.19 kB |
| Everything | 5.36 kB | 1.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