Read OSS

Defu: 100行のディープデフォルトライブラリのアーキテクチャとAPI設計

中級

前提知識

  • JavaScript と Node.js のモジュールシステム(ESM および CommonJS)の基礎知識
  • npm パッケージの構造と package.json のフィールドに関する理解
  • オブジェクトのマージやデフォルト値パターンについての一般的な知識

Defu: 100行のディープデフォルトライブラリのアーキテクチャとAPI設計

あらゆるフレームワークは、ユーザーの設定と適切なデフォルト値をマージする仕組みを必要としています。Object.assign やスプレッド構文で十分だと思うかもしれません。フラットなオブジェクトであれば、確かにそれで事足ります。しかし、ネストされた設定(たとえば Nuxt の nuxt.config.tsvitenitroapp のようなセクションが入れ子になっている場合)を扱う途端、シャローマージではネストされた上書きが静かに失われてしまいます。その問題を解消するのが defu です。TypeScript 約100行で実装された再帰的なデフォルトプロパティ割り当てユーティリティであり、マージ後の型を正確に推論できる型システムを備えています。

Defu とは何か、なぜ存在するのか

Defu は "defaults" から "alts" を省略した名前で、UnJS エコシステムの中核を担うディープデフォルトユーティリティです。Nuxt・Nitro・c12・unenv をはじめとする多数のパッケージから直接依存されています。その役割はシンプルです。ソースオブジェクトとひとつ以上のデフォルトオブジェクトを受け取り、ソース内で欠落しているプロパティや nullish なプロパティをデフォルトから再帰的に補完した新しいオブジェクトを返します。

Object.assign との違いを見てみましょう。

// Object.assign: シャロー — ネストされたデフォルトが失われる
Object.assign({ a: { b: 2 } }, { a: { b: 1, c: 3 } });
// => { a: { b: 2 } }  — プロパティ `c` が消えてしまう!

// defu: ディープ — ネストされたデフォルトが保持される
defu({ a: { b: 2 } }, { a: { b: 1, c: 3 } });
// => { a: { b: 2, c: 3 } }

スプレッド構文でも同じシャローマージの問題が起きます。Lodash の _.defaultsDeep はこの問題を解決しますが、依存サイズがずっと大きくなります。Defu は同じディープマージのセマンティクスを、minify 後 1 KB 未満のパッケージと完全な TypeScript 推論で実現しています。

ディレクトリ構造と各ファイルの役割

リポジトリは驚くほどコンパクトです。重要なファイルの全体像を以下にまとめます。

パス 役割 行数
src/defu.ts コアのマージロジック・ファクトリー・全パブリックエクスポート 約76
src/types.ts 型レベルのディープマージ推論 約111
src/_utils.ts isPlainObject ガード(sindresorhus からフォーク) 約26
lib/defu.cjs 手書きの CJS 互換ラッパー 約11
test/defu.test.ts ランタイム + 型レベルテスト 約253
test/utils.test.ts isPlainObject のエッジケーステスト 約49
test/fixtures/ ES Module 名前空間のテストフィクスチャ 約4

まず目を引くのは、型システムがランタイムより大きいという点です。src/types.ts は112行あるのに対し、src/defu.ts の実際のマージアルゴリズム(_defu 関数)は約47行です。この比率がプロジェクトの優先事項を雄弁に物語っています。型の正確さは後付けではなく、最重要の成果物として扱われているのです。

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

_utils.ts のアンダースコアプレフィックスは、このファイルが内部実装であり公開APIではないことを示す慣習です。エクスポートする関数は isPlainObject のみで、値をディープマージすべきかリーフとして扱うべきかを判断するゲートキーパーの役割を担っています。

公開APIの全体像

Defu がエクスポートするのは、ちょうど5つです。コアモジュールの末尾でまとめて確認できます。

src/defu.ts#L50-L76

エクスポート 用途
defu DefuInstance 標準のディープデフォルトマージ。メインのエクスポート
createDefu (merger?) => DefuFn カスタムマージバリアントを生成するファクトリー
defuFn DefuFn バリアント: ソース値が関数の場合、デフォルト値を引数に呼び出す
defuArrayFn DefuFn バリアント: defuFn と同様だが、配列のデフォルトにのみ適用
Defu 型エクスポート 外部から利用できる型レベルのマージユーティリティ

これらのエクスポートは階層的な関係にあります。

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 だけです。defudefuFndefuArrayFn はすべて、異なる merger コールバック(あるいは何も渡さない場合)を引数に createDefu を呼び出して生成されたインスタンスです。ひとつの生成メカニズムから複数のバリアントを作り出す、教科書的なファクトリーパターンの応用です。

ヒント: 数値を上書きではなく合算するなど、独自のマージ挙動が必要な場合は createDefu を直接使いましょう。再帰的なディープマージのインフラはそのままに、キーごとのロジックだけを差し替えられます。

ファクトリーパターン: createDefu と reduce

ファクトリー関数はわずか5行ですが、2つの重要な設計判断が込められています。

src/defu.ts#L50-L54

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

設計判断1: reduce による複数引数のサポート。 defu(a, b, c) と呼び出すと、引数は左から右へ順に畳み込まれます。初期アキュムレータは空のオブジェクト {} なので、処理の順序は次のようになります。

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

つまり、最も左の引数が常に最高優先度を持ちます。第1引数がソース(ユーザー設定)、それ以降の引数は優先度が下がる順のデフォルト値です。「空の状態から始め、最も具体的な値を先に適用し、より汎用的なデフォルトで残りを補完する」というメンタルモデルと一致しています。

設計判断2: クロージャに閉じた merger コールバック。 merger パラメータは createDefu が返すクロージャの中に閉じ込められます。返された関数を呼び出すたびに、再帰的なすべての _defu 呼び出しで同じ merger が使われます。これはストラテジーパターンの応用です。マージアルゴリズム全体は固定されていますが、各キーをどう処理するかという判断ポイントだけが差し替え可能になっています。

CJS/ESM デュアルパブリッシング戦略

Defu は両方のモジュールシステム向けにリリースされています。package.json の exports マップが、Node.js がどのファイルを読み込むかを決定します。

package.json#L7-L13

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

ESM パス(./dist/defu.mjs)は unbuild が生成したビルド成果物——TypeScript ソースをそのまま変換したもの——を指しています。一方 CJS パスは lib/defu.cjs を指しており、こちらはビルド成果物ではなく手書きのラッパーです。このファイルは lib/ ディレクトリに置かれ、バージョン管理にコミットされています。

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;

なぜ手書きにするのでしょうか。CJS には独特の人間工学的な問題があるからです。利用者は次の両方のパターンが動くことを期待しています。

// パターン1: デフォルトインポートスタイル
const defu = require('defu');
defu({ a: 1 }, { b: 2 });

// パターン2: 名前付きインポートスタイル
const { defu } = require('defu');
defu({ a: 1 }, { b: 2 });

3行目の module.exports = defu でパターン1が成立します——モジュールのデフォルトエクスポートがそのまま関数になります。5行目の module.exports.defu = defu でパターン2が成立します——その関数オブジェクト上に名前付きの defu プロパティも存在するからです。6行目の module.exports.default = defu は、ESM のデフォルトエクスポートをエミュレートする際に明示的な .default プロパティを期待するバンドラー向けの対応です。

この3重代入は Node.js エコシステムでよく知られたパターンですが、自動ビルドツールが正確に再現できないことも多いです。手書きにすることで、defu の作者たちは意図どおりに動作することを確実にしています。

ヒント: CJS/ESM デュアルパッケージを公開する際にビルドツールが適切な CJS interop を生成してくれない場合は、このような手書きラッパーを検討してみましょう。数行のコードで「パッケージがインポートできない」という問題のクラス全体をなくせます。

ビルド・CI・品質パイプライン

ビルドには unbuild——これも UnJS プロジェクトのひとつ——を使用しています。unbuild は package.json の exports マップを読み取り、適切な出力フォーマットを自動生成します。build スクリプトはシンプルそのものです。

"build": "unbuild"

設定ファイルは不要です。unbuild は exports マップから dist/defu.mjsdist/defu.cjsdist/defu.d.ts を生成すべきと自動的に判断します。

.github/workflows/ci.yml の CI パイプラインは、6つのステップを順番に実行します。

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

ハイライトされているステップ——tsc --noEmit——が特に注目に値します。このステップは tsconfig.json で指定された src/test/ の両方に対して TypeScript コンパイラをフル実行しますが、出力ファイルは生成しません。目的は、src/types.ts の型レベルマージロジックが、テストスイートの expectTypeOf アサーションで検証される出力型を正しく推論できているかを確認することです。

これは意図的な関心の分離です。Vitest はランタイムテストを担当します——defu({a: 1}, {b: 2}) が正しい値を返すか? TypeScript は型テストを担当します——その式の{a: number; b: number} と一致するか? CI をパスするには、両方が成功しなければなりません。

TypeScript の設定はストリクトかつミニマルです。

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

strict: true はすべての厳格な型チェックフラグを有効にします。skipLibCheck: truenode_modules の型の再チェックを省略するための実用的な選択で、プロジェクト自身のコードの安全性を犠牲にすることなく、型チェックのステップを高速化します。

次のステップ

ここまでで、アーキテクチャの骨格を把握しました。3つのソースファイル・ファクトリーパターン・デュアルパブリッシング、そしてランタイムの振る舞いと同等に型を重視する CI パイプラインです。第2回では、47行の _defu 関数にズームインし、プロトタイプ汚染ガードから配列の結合、プラガブルな merger フックまで、すべての分岐を追っていきます。実際のマージアルゴリズムはそのサイズが示す以上に奥深く、読み応えのある内容です。