Inside Defu's Recursive Merge: Algorithm, Security, and Extensibility
Prerequisites
- ›Article 1: Architecture and API Design of Defu
- ›JavaScript object fundamentals: Object.assign, prototype chain, Object.getPrototypeOf
- ›Understanding of recursive algorithms
- ›Awareness of prototype pollution as a security concern
Inside Defu's Recursive Merge: Algorithm, Security, and Extensibility
In Part 1, we mapped out defu's architecture — the factory pattern, the five public exports, the dual CJS/ESM publishing strategy. Now we go inside the engine room. The _defu function is roughly 40 lines of code, but each line represents a deliberate decision about how objects should be merged. It handles security (prototype pollution), semantics (nullish skipping, array concatenation), extensibility (the merger hook), and correctness (the isPlainObject guard). Let's trace every branch.
The _defu Function: Line-by-Line Walkthrough
Here's the complete algorithm:
The function signature tells us the shape: it takes a baseObject (the higher-priority source), defaults (the lower-priority fallback), an optional namespace string for context-aware merging, and an optional merger callback for custom logic.
The algorithm proceeds through a five-way decision tree for each key in baseObject:
flowchart TD
Start["for key in baseObject"] --> Security{"key === '__proto__'<br/>or 'constructor'?"}
Security -->|Yes| Skip1[continue — skip key]
Security -->|No| Nullish{"value === null<br/>or undefined?"}
Nullish -->|Yes| Skip2["continue — keep default"]
Nullish -->|No| Merger{"merger callback<br/>returns true?"}
Merger -->|Yes| Skip3["continue — merger handled it"]
Merger -->|No| ArrayCheck{"Both arrays?"}
ArrayCheck -->|Yes| Concat["Concatenate:<br/>[...value, ...default]"]
ArrayCheck -->|No| ObjectCheck{"Both plain objects?"}
ObjectCheck -->|Yes| Recurse["_defu(value, default,<br/>namespace, merger)"]
ObjectCheck -->|No| Override["object[key] = value"]
The ordering of these checks matters. Security comes first — we never even look at the value for poisoned keys. Nullish comes second — a null or undefined source value means "I don't have an opinion, use the default." The merger hook comes third — it gets first crack at non-nullish values before the built-in logic. Only then do we reach the standard array/object/primitive dispatch.
Clone Semantics: Why Object.assign({}, defaults)
Line 15 is easy to overlook, but it's critical:
const object = Object.assign({}, defaults);
This creates a shallow clone of defaults as the starting output object. The algorithm then iterates over baseObject's keys and patches the clone. This design has two important consequences:
-
Neither input is mutated. The output is always a fresh object. If
baseObjecthas a nested object that gets deep-merged with a nested default, the recursion produces a new object at that level too. -
Defaults-first, source-override. By starting with a clone of defaults and then overwriting with source values, any key present only in defaults automatically survives. The
for...inloop only iteratesbaseObject's own and inherited enumerable keys — it never removes a default.
This is the opposite of how Object.assign(target, source) works conceptually. In Object.assign, the second argument overwrites the first. In _defu, the first argument (baseObject/source) has higher priority but it patches a copy of the second argument (defaults). The mental model is: "start with all defaults, then surgically replace the ones the user explicitly provided."
Prototype Pollution Prevention
Lines 18–20 are the security guard:
if (key === "__proto__" || key === "constructor") {
continue;
}
Prototype pollution is a class of attack where malicious input like {"__proto__": {"isAdmin": true}} gets merged into an object, modifying Object.prototype and affecting every object in the runtime. Deep-merge utilities are a classic attack vector because they recursively assign properties — including dangerous ones.
Defu's defense is straightforward: silently skip these keys. The test suite verifies this explicitly:
it("should not override Object prototype", () => {
const payload = JSON.parse(
'{"constructor": {"prototype": {"isAdmin": true}}}',
);
defu({}, payload);
defu(payload, {});
defu(payload, payload);
expect({}.isAdmin).toBe(undefined);
});
The test covers all three argument positions — the malicious payload as source, as defaults, and as both. In every case, {}.isAdmin must remain undefined.
Tip: If you're writing any kind of deep-merge utility, always guard against
__proto__andconstructorkeys. It's the minimum viable defense against prototype pollution. Libraries likedefumake this explicit; many homegrown solutions forget.
The isPlainObject Guard
The isPlainObject function is the gatekeeper that decides whether a value should be recursively deep-merged or treated as an opaque leaf. Without it, defu would happily recurse into Dates, RegExps, class instances, Maps, Sets — all of which would break if their internal properties were spread into a plain object.
The function performs four checks in sequence:
flowchart TD
A["isPlainObject(value)"] --> B{"value === null or<br/>typeof !== 'object'?"}
B -->|Yes| R1[return false]
B -->|No| C{"prototype !== null<br/>AND !== Object.prototype<br/>AND grandparent !== null?"}
C -->|Yes| R2["return false<br/>(class instance, Error, etc.)"]
C -->|No| D{"Symbol.iterator<br/>in value?"}
D -->|Yes| R3["return false<br/>(Array, Set, Map, etc.)"]
D -->|No| E{"Symbol.toStringTag<br/>in value?"}
E -->|Yes| F{"toString === '[object Module]'?"}
F -->|Yes| R4["return true<br/>(ES Module namespace)"]
F -->|No| R5["return false<br/>(Math, Promise, etc.)"]
E -->|No| R6[return true]
Check 1: Null and non-objects. Primitives and null are never plain objects.
Check 2: Prototype chain. A plain object has either Object.prototype as its direct prototype (created via {} or new Object()) or null as its prototype (created via Object.create(null)). Anything else — class instances, Error, Date, RegExp — has a longer prototype chain. The check Object.getPrototypeOf(prototype) !== null catches Object.create(null) objects, which have a null prototype and no grandparent.
Check 3: Symbol.iterator. This rejects anything iterable: arrays, Sets, Maps, TypedArrays, arguments objects. These all have Symbol.iterator on their prototype chain.
Check 4: Symbol.toStringTag. Many built-in objects have Symbol.toStringTag (e.g., Math has [object Math], Promise has [object Promise]). The function rejects all of them except ES Module namespace objects, which have [object Module]. This exception is crucial — when you do import * as config from './defaults', the resulting namespace object should be mergeable.
The test suite exercises these edge cases exhaustively:
Note that { [Symbol.toStringTag]: true } returns false (line 46) and { [Symbol.iterator]: true } returns false (line 47) — even plain objects are rejected if they happen to have these symbols as own properties. This is conservative: it prevents accidental deep-merging of objects that look like iterables or tagged objects.
The Merger Strategy Pattern
As we saw in Part 1, createDefu accepts an optional merger callback. Inside _defu, this callback is invoked at line 28:
if (merger && merger(object, key, value, namespace)) {
continue;
}
The contract is simple: the merger receives the current output object, the key being processed, the source value, and the dot-separated namespace. If it returns a truthy value, _defu skips its built-in logic for that key. If it returns falsy (or undefined), the standard array/object/primitive dispatch takes over.
This is the Strategy Pattern in its most compact form. Let's see how defuFn and defuArrayFn use it:
// defuFn: if the source value is a function, call it with the default
export const defuFn = createDefu((object, key, currentValue) => {
if (object[key] !== undefined && typeof currentValue === "function") {
object[key] = currentValue(object[key]);
return true;
}
});
// defuArrayFn: same, but only when the default is an array
export const defuArrayFn = createDefu((object, key, currentValue) => {
if (Array.isArray(object[key]) && typeof currentValue === "function") {
object[key] = currentValue(object[key]);
return true;
}
});
The difference is subtle but important. defuFn calls the function merger whenever the source provides a function and the default has any defined value. defuArrayFn is more restrictive — it only calls the function when the default is specifically an array. In both cases, a function that doesn't match the condition falls through to standard merge logic (where it'd be treated as a plain value).
The namespace parameter enables context-aware merging. Consider this test:
const ext = createDefu((obj, key, val, namespace) => {
if (key === "modules") {
obj[key] = namespace + ":" + [...val, ...obj[key]].sort().join(",");
return true;
}
});
const obj1 = { modules: ["A"], foo: { bar: { modules: ["X"] } } };
const obj2 = { modules: ["B"], foo: { bar: { modules: ["Y"] } } };
expect(ext(obj1, obj2)).toEqual({
modules: ":A,B",
foo: { bar: { modules: "foo.bar:X,Y" } },
});
The namespace at the top level is "", producing ":A,B". At the nested foo.bar level, it's "foo.bar", producing "foo.bar:X,Y". This lets you write mergers that behave differently depending on where in the object tree they are — useful for config systems where the same key name means different things at different nesting levels.
Multi-Argument Folding with reduce
As discussed in Part 1, createDefu uses Array.reduce to fold multiple arguments:
return (...arguments_) =>
arguments_.reduce((p, c) => _defu(p, c, "", merger), {} as any);
Let's visualize how defu({ a: 1 }, { b: 2, a: 'x' }, { c: 3, a: 'x', b: 'x' }) processes:
sequenceDiagram
participant R as reduce accumulator
participant S as {a: 1}
participant D1 as {b: 2, a: 'x'}
participant D2 as {c: 3, a: 'x', b: 'x'}
R->>S: _defu({}, {a:1}) → {a: 1}
Note right of S: Start with empty, apply source
S->>D1: _defu({a:1}, {b:2, a:'x'}) → {a:1, b:2}
Note right of D1: a:1 wins over a:'x', b:2 fills gap
D1->>D2: _defu({a:1,b:2}, {c:3,a:'x',b:'x'}) → {a:1,b:2,c:3}
Note right of D2: a and b already set, c:3 fills gap
The initial accumulator {} is important — the first _defu call clones {a: 1} as defaults, then applies {} as the source (which has no keys). The result is effectively a clone of {a: 1}. Each subsequent call merges the accumulated result with the next defaults object. Because _defu always starts with Object.assign({}, defaults), the accumulated higher-priority values always win.
The test at test/defu.test.ts#L92-L104 verifies both the runtime result and the inferred type:
const result = defu({ a: 1 }, { b: 2, a: "x" }, { c: 3, a: "x", b: "x" });
expect(result).toEqual({ a: 1, b: 2, c: 3 });
expectTypeOf(result).toMatchTypeOf<{
a: string | number;
b: string | number;
c: number;
}>();
Notice that the runtime value of a is 1 (a number), but the type is string | number. The type system can't know which runtime path will win — it just knows that a could be either type. We'll explore this type-level behavior in depth in Part 3.
ES Module Namespace and Custom Merger Tests
One particularly interesting edge case is merging ES Module namespace objects (the result of import * as foo from '...'). These objects have a null prototype and Symbol.toStringTag set to "Module". Without the exception in isPlainObject, defu would refuse to deep-merge them.
The test fixture is minimal:
test/fixtures/index.ts#L1 re-exports a default from test/fixtures/nested.ts:
// index.ts
export { default as exp } from "./nested.js";
// nested.ts
export default { nested: 1 };
The test imports the entire namespace and merges it:
import * as asteriskImport from "./fixtures/";
it("works with asterisk-import", () => {
expect(
defu(asteriskImport, { a: 2, exp: { anotherNested: 2 } }),
).toStrictEqual({
a: 2,
exp: { anotherNested: 2, nested: 1 },
});
});
The asteriskImport object is an ES Module namespace — isPlainObject returns true for it because of the [object Module] exception. The exp property is a plain object { nested: 1 }, which gets deep-merged with { anotherNested: 2 }. Without the Symbol.toStringTag check, the namespace object would be treated as a leaf, and the deep merge into exp would never happen.
Tip: If you're building a config loader that uses
import()orimport *, make sure your merge utility handles ES Module namespaces. Defu does; many alternatives don't.
What's Next
We've now traced every runtime decision in _defu — from the security guards at the top to the array/object/primitive dispatch at the bottom. The runtime is fully understood. But there's a parallel implementation we haven't touched: the ~112-line type system in src/types.ts that mirrors each of these runtime decisions as recursive conditional types. In Part 3, we'll dissect that type-level deep merge — how Defu<S, D> processes variadic tuple arguments, how MergeObjects applies nullish-aware key merging, and how Merge dispatches through the same decision tree we just traced, but entirely at the type level.