Type-Level Deep Merge: How Defu Models Recursive Object Merging in TypeScript
Prerequisites
- ›Articles 1-2: Architecture and Recursive Merge Algorithm
- ›Intermediate TypeScript: generics, conditional types, infer keyword
- ›Familiarity with mapped types and recursive type aliases
- ›Understanding of TypeScript's type-level programming patterns
Type-Level Deep Merge: How Defu Models Recursive Object Merging in TypeScript
In Part 2, we traced every runtime branch in _defu: nullish values are skipped, arrays are concatenated, plain objects are recursed into, and everything else is overridden. Now imagine implementing all of that logic again — not at runtime, but in the type system. That's what src/types.ts does. At 112 lines, it's the largest file in the project, encoding a recursive conditional type that mirrors the runtime algorithm so that defu({ a: 1 }, { b: 'hello' }) doesn't just return the right value — it returns the right type: { a: number; b: string }.
This article is for developers who want to understand how to build type systems that faithfully model complex runtime behavior. We'll go type-by-type through the entire file.
Overview: Why Mirror Runtime Logic in Types?
Defu isn't just used in application code — it's used in framework configuration. When Nuxt calls defu(userConfig, frameworkDefaults), the resulting object feeds into dozens of downstream systems that expect specific types. If the merged type were just any or a loose intersection, you'd lose autocompletion, miss type errors, and spend hours debugging config issues that TypeScript should have caught.
The goal is 1:1 correspondence: every runtime decision in _defu has a type-level counterpart. When the runtime skips a null source value and keeps the default, the type system should infer the default's type. When the runtime concatenates arrays, the type should produce Array<SourceElement | DefaultElement>. When the runtime refuses to deep-merge a Date or RegExp, the type should produce a union rather than a merged object.
Let's see how the complete type file is organized:
flowchart TD
Input["Input, IgnoredInput<br/>Merger, nullish<br/>(foundation types)"] --> MO["MergeObjects<D, Def><br/>(key-by-key mapped type)"]
Input --> MA["MergeArrays<D, S><br/>(array concat type)"]
MO --> M["Merge<D, Def><br/>(dispatch chain)"]
MA --> M
M --> MO
MO --> Defu["Defu<S, D><br/>(variadic tuple recursion)"]
Defu --> DefuFn["DefuFn<br/>(function signature)"]
DefuFn --> DefuInstance["DefuInstance<br/>(interface with fn, arrayFn, extend)"]
Helper Types and Guards
The file opens with foundation types that constrain what defu accepts:
export type Input = Record<string | number | symbol, any>;
export type IgnoredInput =
| boolean
| number
| null
| any[]
| Record<never, any>
| undefined;
export type Merger = <T extends Input, K extends keyof T>(
object: T, key: keyof T, value: T[K], namespace: string,
) => any;
type nullish = null | undefined | void;
Input is the type-level equivalent of "a plain object" — any record with string, number, or symbol keys. IgnoredInput represents the non-object values that _defu silently skips at runtime (recall the if (!isPlainObject(defaults)) guard on line 11 of _defu). The nullish type captures the three bottom types that the runtime treats as "no opinion — use the default."
Record<never, any> in IgnoredInput is worth noting — it's the type of {} in TypeScript. When you pass an empty object as one of the defaults, it should be treated as having no properties to contribute, which is exactly what IgnoredInput allows the tuple recursion to skip.
Defu<S, D>: Variadic Tuple Recursion
This is the top-level type that users interact with (either directly via the exported Defu type or implicitly through defu()'s return type):
export type Defu<
S extends Input,
D extends Array<Input | IgnoredInput>,
> = D extends [infer F, ...infer Rest]
? F extends Input
? Rest extends Array<Input | IgnoredInput>
? Defu<MergeObjects<S, F>, Rest>
: MergeObjects<S, F>
: F extends IgnoredInput
? Rest extends Array<Input | IgnoredInput>
? Defu<S, Rest>
: S
: S
: S;
This mirrors the runtime's Array.reduce. At runtime, arguments_.reduce((p, c) => _defu(p, c, "", merger), {}) folds left through the arguments. At the type level, Defu uses TypeScript's variadic tuple pattern [infer F, ...infer Rest] to peel off one defaults type at a time.
flowchart TD
Start["Defu<S, [D1, D2, D3]>"] --> Extract["D = [infer F=D1, ...Rest=[D2,D3]]"]
Extract --> Check{"F extends Input?"}
Check -->|Yes| Merge1["Defu<MergeObjects<S, D1>, [D2, D3]>"]
Check -->|No| CheckIgnored{"F extends IgnoredInput?"}
CheckIgnored -->|Yes| Skip["Defu<S, [D2, D3]><br/>(skip this default)"]
CheckIgnored -->|No| Done1["S (bail out)"]
Merge1 --> Extract2["D = [infer F=D2, ...Rest=[D3]]"]
Extract2 --> Merge2["Defu<MergeObjects<MergeObjects<S,D1>, D2>, [D3]>"]
Merge2 --> Extract3["D = [infer F=D3, ...Rest=[]]"]
Extract3 --> Final["D3 extends Input → MergeObjects<..., D3><br/>Rest is [] → base case"]
The key insight: if F (the current defaults type) is an IgnoredInput (like null, boolean, or undefined), the recursion simply skips it and continues with Rest. This is the type-level equivalent of the runtime's if (!isPlainObject(defaults)) return _defu(baseObject, {}, ...) — non-objects are ignored.
Tip: If you're building variadic generic types, the
[infer F, ...infer Rest]pattern is your recursion primitive. Always include a base case (here:D extends []implicitly returnsS) to avoid infinite type recursion.
MergeObjects: Key-by-Key Type Merging
This is the workhorse — a mapped type that iterates over every key shared between source and defaults, applying nullish-aware merge logic:
export type MergeObjects<
Destination extends Input,
Defaults extends Input,
> = Destination extends Defaults
? Destination
: Omit<Destination, keyof Destination & keyof Defaults> &
Omit<Defaults, keyof Destination & keyof Defaults> & {
-readonly [Key in keyof Destination &
keyof Defaults]: Destination[Key] extends nullish
? Defaults[Key] extends nullish
? nullish
: Defaults[Key]
: Defaults[Key] extends nullish
? Destination[Key]
: Merge<Destination[Key], Defaults[Key]>;
};
Let's break this into parts.
The short-circuit: Destination extends Defaults ? Destination. If the destination type is already a subtype of defaults (i.e., it already satisfies all the defaults' constraints), there's nothing to merge — just return Destination. This is an optimization that avoids unnecessary type computation.
The three-part intersection. For keys that don't overlap, the types are straightforward:
Omit<Destination, keyof Destination & keyof Defaults>— keys only in DestinationOmit<Defaults, keyof Destination & keyof Defaults>— keys only in Defaults
For overlapping keys (keyof Destination & keyof Defaults), the mapped type applies the nullish decision tree:
flowchart TD
K["Key in both Destination and Defaults"] --> DestNull{"Destination[Key]<br/>extends nullish?"}
DestNull -->|Yes| DefNull{"Defaults[Key]<br/>extends nullish?"}
DefNull -->|Yes| BothNull["nullish"]
DefNull -->|No| UseDefault["Defaults[Key]"]
DestNull -->|No| DefNull2{"Defaults[Key]<br/>extends nullish?"}
DefNull2 -->|Yes| UseDest["Destination[Key]"]
DefNull2 -->|No| DeepMerge["Merge<Destination[Key], Defaults[Key]>"]
This exactly mirrors the runtime: if the source value is null/undefined, use the default; if the default is null/undefined, use the source; if neither is nullish, recurse via Merge. The -readonly modifier strips readonly from keys, matching the runtime behavior where cloned objects lose their readonly constraints.
Merge: The Type-Level Dispatch Chain
Merge is the type equivalent of _defu's inner decision tree — the part that handles arrays, functions, RegExps, Promises, and plain objects:
The chain is a nested conditional type that checks both Destination and Defaults in sequence:
- Nullish dispatch (lines 79–84): If either side is nullish, return the other (or
nullishif both are). - Array dispatch (lines 86–89): If both are arrays, use
MergeArrays. If only one is an array, produce a union. - Function/RegExp/Promise guard (lines 92–105): If either side is a Function, RegExp, or Promise, produce a union
Destination | Defaults. No deep merge. - Object recursion (lines 107–111): If both extend
Input, recurse viaMergeObjects. Otherwise, union.
The Function/RegExp/Promise checks mirror the runtime's isPlainObject guard. At runtime, isPlainObject(new Date()) returns false, so dates are never deep-merged. At the type level, Date extends Function? No. But it also doesn't extend Input in a way that would trigger MergeObjects — it falls through to the final Destination | Defaults union.
Notice that the checks are duplicated — first for Destination (lines 92–97), then for Defaults (lines 100–105). This is because either side being non-mergeable should prevent deep recursion. If you have { a: () => void } merged with { a: { nested: true } }, the result type is (() => void) | { nested: true }, not a merged object.
MergeArrays: Modeling Array Concatenation
The array type is refreshingly simple:
export type MergeArrays<Destination, Source> = Destination extends Array<
infer DestinationType
>
? Source extends Array<infer SourceType>
? Array<DestinationType | SourceType>
: Source | Array<DestinationType>
: Source | Destination;
At runtime, _defu concatenates arrays: [...value, ...object[key]]. The resulting array contains elements from both sources. TypeScript models this as Array<DestinationType | SourceType> — the element type is the union of both arrays' element types.
The test at test/defu.test.ts#L42-L52 verifies this:
const item1 = { name: "Name", age: 21 };
const item2 = { name: "Name", age: "42" };
const result = defu({ items: [item1] }, { items: [item2] });
expectTypeOf(result).toMatchTypeOf<{
items: Array<
{ name: string; age: number } | { name: string; age: string }
>;
}>();
The runtime array is [item1, item2]. The type is Array<{name: string; age: number} | {name: string; age: string}>. The type system can't know the order or count of elements, but it correctly captures that the array might contain elements from either source.
Runtime ↔ Type Parity: A Side-by-Side Comparison
Here's the complete correspondence between runtime decisions and type-level implementations:
Runtime decision in _defu |
Type-level counterpart | Location |
|---|---|---|
if (!isPlainObject(defaults)) — skip non-objects |
F extends IgnoredInput ? Defu<S, Rest> : S in Defu |
types.ts L44-L47 |
value === null || value === undefined — skip nullish |
Destination[Key] extends nullish ? Defaults[Key] in MergeObjects |
types.ts L27-L30 |
Array.isArray(value) && Array.isArray(object[key]) — concat |
Destination extends Array<any> ? ... MergeArrays in Merge |
types.ts L86-L89 |
isPlainObject(value) && isPlainObject(object[key]) — recurse |
Destination extends Input ? Defaults extends Input ? MergeObjects in Merge |
types.ts L107-L109 |
| Else — override with source value | Destination | Defaults (union fallback) throughout Merge |
types.ts L89, L93, etc. |
Object.assign({}, defaults) — clone defaults |
Omit<Defaults, shared keys> in MergeObjects — defaults-only keys survive |
types.ts L25 |
arguments_.reduce(...) — left-fold |
Defu<MergeObjects<S, F>, Rest> — recursive tuple peeling |
types.ts L42 |
The one intentional divergence is the override case. At runtime, if the source has a non-nullish value and the default has a non-nullish value of a different type (say, a function vs. a number), the source wins — the output is the source value. But at the type level, since we don't know runtime values, we produce Destination | Defaults — a union of both possible types. This is the sound choice: the type says "it could be either," and the runtime narrows it to the specific value.
Tip: When modeling runtime behavior in types, unions are your friend for cases where the type system can't determine which branch will be taken. A union is always sound; a pick-one-side type would be unsound if the runtime could go either way.
The DefuInstance Interface
The public defu export isn't just a function — it's a DefuInstance that also carries fn, arrayFn, and extend as properties:
export interface DefuInstance {
<Source extends Input, Defaults extends Array<Input | IgnoredInput>>(
source: Source | IgnoredInput,
...defaults: Defaults
): Defu<Source, Defaults>;
fn: DefuFn;
arrayFn: DefuFn;
extend(merger?: Merger): DefuFn;
}
The call signature allows source to be Source | IgnoredInput — you can pass null or undefined as the first argument and it works (the runtime handles it; the type system types it correctly through Defu). The ...defaults rest parameter with type Defaults extends Array<Input | IgnoredInput> enables the variadic tuple inference that powers Defu<S, D>.
Type-Level Testing with expectTypeOf
Defu's test suite doesn't just test runtime values — it tests types. Every it() block that creates a merge result also asserts the inferred type using expectTypeOf from the expect-type library:
const result = defu({ a: "c" }, { a: "bbb", d: "c" });
expect(result).toEqual({ a: "c", d: "c" });
expectTypeOf(result).toMatchTypeOf<{ a: string; d: string }>();
More complex scenarios test multi-defaults inference (test/defu.test.ts#L92-L104), partial merges with undefined sources (test/defu.test.ts#L124-L164), and interface-based config merging:
interface SomeConfig { foo: string; }
interface SomeOtherConfig { bar: string[]; }
interface ThirdConfig { baz: number[]; }
interface ExpectedMergedType { foo: string; bar: string[]; baz: number[]; }
expectTypeOf(
defu({} as SomeConfig, {} as SomeOtherConfig, {} as ThirdConfig),
).toMatchTypeOf<ExpectedMergedType>();
These expectTypeOf assertions don't run at runtime — they're purely compile-time checks. That's why tsc --noEmit is a separate CI step (as we covered in Part 1). Vitest executes the expect() assertions; TypeScript validates the expectTypeOf() assertions. Both must pass.
flowchart LR
subgraph "vitest run"
A["expect(result).toEqual(...)"] --> B["Runtime correctness ✓"]
end
subgraph "tsc --noEmit"
C["expectTypeOf(result).toMatchTypeOf<T>()"] --> D["Type correctness ✓"]
end
B --> E["CI green"]
D --> E
This dual-verification approach is essential for a library like defu. A type regression (where the runtime still works but the inferred type becomes any or too wide) would silently break every downstream consumer's type checking. By testing types explicitly, defu catches these regressions before they ship.
Wrapping Up the Series
Across these three articles, we've traced defu from its architectural bones to its algorithmic core to its type-level brain. The library is a case study in how much design can fit into ~100 lines of runtime code:
- Part 1 showed the factory pattern, dual CJS/ESM publishing, and a CI pipeline that treats types as first-class.
- Part 2 traced the five-way decision tree in
_defu, theisPlainObjectguard, prototype pollution prevention, and the Strategy Pattern merger hook. - Part 3 revealed how every one of those runtime decisions is mirrored in recursive conditional types, producing precise merged types that flow through to downstream consumers.
The broader lesson is architectural: when your runtime logic has clear decision branches, you can (and often should) model those same branches in the type system. Defu's 112-line type file isn't accidental complexity — it's the price of type-safe deep defaults in a framework ecosystem where config objects are deeply nested, variadic, and often partially defined. That investment pays dividends every time a Nuxt user gets correct autocompletion on their merged configuration.