Read OSS

Type-Level Deep Merge: How Defu Models Recursive Object Merging in TypeScript

Advanced

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:

src/types.ts#L1-L111

flowchart TD
    Input["Input, IgnoredInput<br/>Merger, nullish<br/>(foundation types)"] --> MO["MergeObjects&lt;D, Def&gt;<br/>(key-by-key mapped type)"]
    Input --> MA["MergeArrays&lt;D, S&gt;<br/>(array concat type)"]
    MO --> M["Merge&lt;D, Def&gt;<br/>(dispatch chain)"]
    MA --> M
    M --> MO
    MO --> Defu["Defu&lt;S, D&gt;<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:

src/types.ts#L1-L17

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):

src/types.ts#L36-L49

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&lt;S, [D1, D2, D3]&gt;"] --> Extract["D = [infer F=D1, ...Rest=[D2,D3]]"]
    Extract --> Check{"F extends Input?"}
    Check -->|Yes| Merge1["Defu&lt;MergeObjects&lt;S, D1&gt;, [D2, D3]&gt;"]
    Check -->|No| CheckIgnored{"F extends IgnoredInput?"}
    CheckIgnored -->|Yes| Skip["Defu&lt;S, [D2, D3]&gt;<br/>(skip this default)"]
    CheckIgnored -->|No| Done1["S (bail out)"]
    Merge1 --> Extract2["D = [infer F=D2, ...Rest=[D3]]"]
    Extract2 --> Merge2["Defu&lt;MergeObjects&lt;MergeObjects&lt;S,D1&gt;, D2&gt;, [D3]&gt;"]
    Merge2 --> Extract3["D = [infer F=D3, ...Rest=[]]"]
    Extract3 --> Final["D3 extends Input → MergeObjects&lt;..., D3&gt;<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 returns S) 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:

src/types.ts#L19-L34

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 Destination
  • Omit<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&lt;Destination[Key], Defaults[Key]&gt;"]

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:

src/types.ts#L77-L111

The chain is a nested conditional type that checks both Destination and Defaults in sequence:

  1. Nullish dispatch (lines 79–84): If either side is nullish, return the other (or nullish if both are).
  2. Array dispatch (lines 86–89): If both are arrays, use MergeArrays. If only one is an array, produce a union.
  3. Function/RegExp/Promise guard (lines 92–105): If either side is a Function, RegExp, or Promise, produce a union Destination | Defaults. No deep merge.
  4. Object recursion (lines 107–111): If both extend Input, recurse via MergeObjects. 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:

src/types.ts#L69-L75

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:

src/types.ts#L59-L67

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:

test/defu.test.ts#L11-L14

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&lt;T&gt;()"] --> 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, the isPlainObject guard, 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.