Read OSS

Defu: Architecture and API Design of a 100-Line Deep Defaults Library

Intermediate

Prerequisites

  • Basic JavaScript and Node.js module systems (ESM and CommonJS)
  • Familiarity with npm package structure and package.json fields
  • General understanding of object merging / defaults patterns

Defu: Architecture and API Design of a 100-Line Deep Defaults Library

Every framework needs a way to merge user configuration with sensible defaults. You'd think Object.assign or spread syntax would suffice — and for flat objects, they do. But the moment you have nested configuration (think Nuxt's nuxt.config.ts with its nested vite, nitro, and app sections), shallow merging silently drops your nested overrides. That's the gap defu fills: recursive default property assignment in roughly 100 lines of TypeScript, with a type system that knows exactly what the merged output looks like.

What Is Defu and Why Does It Exist?

Defu — "defaults" with the "alts" dropped — is the deep-defaults utility at the heart of the UnJS ecosystem. It's a direct dependency of Nuxt, Nitro, c12, unenv, and dozens of other packages. Its job is deceptively simple: given a source object and one or more defaults objects, produce a new object where every missing or nullish property in the source is filled from the defaults, recursively.

Here's the critical difference from Object.assign:

// Object.assign: shallow — nested defaults are lost
Object.assign({ a: { b: 2 } }, { a: { b: 1, c: 3 } });
// => { a: { b: 2 } }  — property `c` is gone!

// defu: deep — nested defaults are preserved
defu({ a: { b: 2 } }, { a: { b: 1, c: 3 } });
// => { a: { b: 2, c: 3 } }

Spread-based approaches have the same shallow problem. Lodash's _.defaultsDeep solves it but brings a much larger dependency. Defu provides the same deep-merge-defaults semantics in a package that minifies to under 1 KB, with full TypeScript inference.

Directory Structure and File Roles

The repository is remarkably small. Here's the complete layout of files that matter:

Path Role Lines
src/defu.ts Core merge logic, factory, all public exports ~76
src/types.ts Type-level deep merge inference ~111
src/_utils.ts isPlainObject guard (forked from sindresorhus) ~26
lib/defu.cjs Hand-written CJS compatibility wrapper ~11
test/defu.test.ts Runtime + type-level tests ~253
test/utils.test.ts isPlainObject edge case tests ~49
test/fixtures/ ES Module namespace test fixtures ~4

The first thing that jumps out: the type system is larger than the runtime. src/types.ts weighs in at 112 lines; the actual merge algorithm in src/defu.ts is about 47 lines (the _defu function). This ratio tells you a lot about the project's priorities — type correctness isn't an afterthought, it's a first-class deliverable.

graph LR
    subgraph "src/"
        defu["defu.ts<br/>(runtime + exports)"]
        types["types.ts<br/>(type-level merge)"]
        utils["_utils.ts<br/>(isPlainObject)"]
    end
    subgraph "lib/"
        cjs["defu.cjs<br/>(CJS wrapper)"]
    end
    subgraph "dist/ (built)"
        mjs["defu.mjs"]
        dcjs["defu.cjs"]
        dts["defu.d.ts"]
    end

    defu --> utils
    defu --> types
    cjs --> dcjs
    mjs -.-> dts

The _utils.ts file's leading underscore is a convention signaling it's internal — not part of the public API. It exports a single function, isPlainObject, which is the gatekeeper deciding whether a value should be deep-merged or treated as a leaf.

The Public API Surface

Defu exports exactly five things. Let's see them all at the bottom of the core module:

src/defu.ts#L50-L76

Export Type Purpose
defu DefuInstance Standard deep-defaults merge. The primary export.
createDefu (merger?) => DefuFn Factory to create custom merge variants
defuFn DefuFn Variant: if source value is a function, call it with the default value
defuArrayFn DefuFn Variant: like defuFn, but only for array defaults
Defu Type export The type-level merge utility for external use

The relationship between these exports is hierarchical:

flowchart TD
    createDefu["createDefu(merger?)"] --> defu["defu = createDefu()"]
    createDefu --> defuFn["defuFn = createDefu(fnMerger)"]
    createDefu --> defuArrayFn["defuArrayFn = createDefu(arrayFnMerger)"]
    createDefu --> custom["yourCustom = createDefu(yourMerger)"]

    style createDefu fill:#f0db4f,color:#000

createDefu is the only "real" constructor. defu, defuFn, and defuArrayFn are all instances produced by calling it with different (or no) merger callbacks. This is a textbook application of the Factory pattern — one creation mechanism, multiple product variants.

Tip: If you need custom merge behavior — say, summing numbers instead of overriding — use createDefu directly. You get the same recursive deep-merge infrastructure with your own per-key logic injected.

The Factory Pattern: createDefu and reduce

The factory function is just five lines, but it encodes two important design decisions:

src/defu.ts#L50-L54

export function createDefu(merger?: Merger): DefuFunction {
  return (...arguments_) =>
    arguments_.reduce((p, c) => _defu(p, c, "", merger), {} as any);
}

Decision 1: Multi-argument support via reduce. When you call defu(a, b, c), the arguments are folded left-to-right. The initial accumulator is an empty object {}, so the processing order is:

sequenceDiagram
    participant Acc as Accumulator ({})
    participant A as Arg 1 (source)
    participant B as Arg 2 (defaults)
    participant C as Arg 3 (more defaults)

    Acc->>A: _defu({}, a) → result₁
    Note over Acc,A: Source properties win
    A->>B: _defu(result₁, b) → result₂
    Note over A,B: a's values win over b's
    B->>C: _defu(result₂, c) → result₃
    Note over B,C: a's and b's values win over c's

This means the leftmost argument always has highest priority. The first argument is your source (user config), and subsequent arguments are defaults in descending priority. This matches the mental model: "start with nothing, apply the most-specific values first, fill in gaps with progressively more generic defaults."

Decision 2: Closed-over merger callback. The merger parameter is captured in the closure returned by createDefu. Every call to the returned function will use that same merger for every recursive _defu invocation. This is the Strategy Pattern — the merge algorithm is fixed, but one decision point (what to do with each key) is pluggable.

Dual CJS/ESM Publishing Strategy

Defu ships to both module systems. The package.json exports map tells Node.js which file to load:

package.json#L7-L13

"exports": {
  ".": {
    "types": "./dist/defu.d.ts",
    "import": "./dist/defu.mjs",
    "require": "./lib/defu.cjs"
  }
}

The ESM path (./dist/defu.mjs) points to the unbuild output — a straightforward transpilation of the TypeScript source. The CJS path, however, points to lib/defu.cjs — a hand-written wrapper, not a build artifact. This file lives in lib/ and is checked into version control.

lib/defu.cjs#L1-L11

const { defu, createDefu, defuFn, defuArrayFn } = require('../dist/defu.cjs');

module.exports = defu;

module.exports.defu = defu;
module.exports.default = defu;

module.exports.createDefu = createDefu;
module.exports.defuFn = defuFn;
module.exports.defuArrayFn = defuArrayFn;

Why hand-write this? Because CJS has a peculiar ergonomic problem: consumers want both of these patterns to work:

// Pattern 1: default import style
const defu = require('defu');
defu({ a: 1 }, { b: 2 });

// Pattern 2: named import style
const { defu } = require('defu');
defu({ a: 1 }, { b: 2 });

Line 3 (module.exports = defu) makes Pattern 1 work — the module's default export is the function. Line 5 (module.exports.defu = defu) makes Pattern 2 work — there's also a named defu property on that function. Line 6 (module.exports.default = defu) supports bundlers that expect an explicit .default property when emulating ESM default exports.

This triple-assignment trick is a well-known pattern in the Node.js ecosystem, but it's one that automated build tools often get wrong. By hand-writing it, the defu authors ensure it works exactly as intended.

Tip: If you're publishing a dual CJS/ESM package and your build tool doesn't generate the right CJS interop, consider a hand-written wrapper like this one. It's only a few lines, and it eliminates an entire class of "I can't import your package" issues.

Build, CI, and Quality Pipeline

The build uses unbuild — another UnJS project — which reads package.json's exports map and auto-generates the right output formats. The build script is simply:

"build": "unbuild"

No config file needed. Unbuild infers that it should produce dist/defu.mjs, dist/defu.cjs, and dist/defu.d.ts from the exports map.

The CI pipeline in .github/workflows/ci.yml runs six sequential steps:

flowchart LR
    A[pnpm install] --> B[pnpm lint]
    B --> C[pnpm build]
    C --> D["pnpm test:types<br/>(tsc --noEmit)"]
    D --> E["pnpm vitest<br/>--coverage"]
    E --> F[codecov upload]

    style D fill:#f0db4f,color:#000

The highlighted step — tsc --noEmit — is the interesting one. This runs the full TypeScript compiler against both src/ and test/ (as specified in tsconfig.json) without producing output. Its purpose is to verify that the type-level merge logic in src/types.ts correctly infers the output types tested by expectTypeOf assertions in the test suite.

This is a deliberate separation of concerns. Vitest runs the runtime tests — does defu({a: 1}, {b: 2}) return the right value? TypeScript checks the type tests — does the type of that expression match {a: number; b: number}? Both must pass for CI to go green.

The TypeScript configuration itself is strict but minimal:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "test"]
}

strict: true enables all strict type-checking flags. skipLibCheck: true avoids re-checking node_modules types — a pragmatic choice that speeds up the type-check step without sacrificing safety in the project's own code.

What's Next

We've seen the architectural skeleton: three source files, a factory pattern, dual publishing, and a CI pipeline that treats types as seriously as runtime behavior. In Part 2, we'll zoom into the 47-line _defu function and trace every decision branch — from prototype pollution guards to array concatenation to the pluggable merger hook. That's where the actual merge algorithm lives, and it's more nuanced than its size suggests.