Read OSS

型レベルのDeep Merge:DefuがTypeScriptで再帰的オブジェクトマージをモデル化する方法

上級

前提知識

  • 第1・2回:アーキテクチャと再帰マージアルゴリズム
  • TypeScript中級:ジェネリクス、条件型、inferキーワード
  • mapped typeと再帰的型エイリアスの基礎知識
  • TypeScriptの型レベルプログラミングパターンへの理解

型レベルのDeep Merge:DefuがTypeScriptで再帰的オブジェクトマージをモデル化する方法

第2回では、_defu のすべてのランタイム分岐を追いました。nullishな値はスキップ、配列は結合、プレーンオブジェクトは再帰、それ以外は上書きという動作です。では、そのロジック全体をランタイムではなく型システムの中で実装するとしたらどうなるでしょうか。それを担っているのが src/types.ts です。112行というこのプロジェクト最大のファイルは、ランタイムアルゴリズムを忠実に反映した再帰条件型をエンコードしています。その結果、defu({ a: 1 }, { b: 'hello' }) は正しい値を返すだけでなく、正しい、つまり { a: number; b: string } を返すようになります。

この記事は、複雑なランタイムの振る舞いを忠実にモデル化する型システムの構築方法を学びたい開発者のために書いています。ファイル全体を型ごとに順を追って解説していきましょう。

概要:なぜランタイムロジックを型で再現するのか?

Defuはアプリケーションコードだけでなく、フレームワーク設定にも広く使われています。Nuxtが defu(userConfig, frameworkDefaults) を呼び出すとき、その戻り値は特定の型を期待する多数の下流システムに渡されます。もしマージ後の型が any や緩い intersection 型であれば、オートコンプリートは効かなくなり、型エラーは見逃され、TypeScriptが本来防いでくれるはずの設定バグを時間をかけてデバッグする羽目になります。

目指すのは1対1の対応です。_defu のあらゆるランタイム決定に、型レベルの対応物を持たせます。ランタイムが null のソース値をスキップしてデフォルトを使う場合、型システムはデフォルトの型を推論します。ランタイムが配列を結合する場合、型は Array<SourceElement | DefaultElement> を生成します。ランタイムが DateRegExp のdeep mergeを拒否する場合、型はマージされたオブジェクトではなくユニオン型を生成します。

型ファイル全体がどのように構成されているか、まず俯瞰してみましょう。

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)"]

ヘルパー型とガード

ファイルの冒頭は、defuが受け取れる値を制約するための基盤となる型から始まります。

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 は型レベルでの「プレーンオブジェクト」——文字列・数値・シンボルをキーに持つ任意のレコードです。IgnoredInput は、ランタイムで _defu が黙ってスキップする非オブジェクト値を表します(_defu の11行目にある if (!isPlainObject(defaults)) ガードを思い出してください)。nullish 型は、ランタイムが「意見なし——デフォルトを使え」と判断する3つのボトム型をまとめたものです。

IgnoredInput 内の Record<never, any> にも注目してください。これはTypeScriptにおける {} の型です。空オブジェクトをデフォルトの一つとして渡しても、提供するプロパティは何もないため無視される——IgnoredInput はタプル再帰がそれをスキップできるよう、まさにその意図を型で表現しています。

Defu<S, D>:可変長タプル再帰

これは、ユーザーが直接触れるトップレベルの型です(エクスポートされた Defu 型を通じて直接使うか、defu() の戻り値型として暗黙的に使われます)。

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;

これはランタイムの Array.reduce を型で再現したものです。ランタイムでは arguments_.reduce((p, c) => _defu(p, c, "", merger), {}) が引数を左から順に畳み込みます。型レベルでは、Defu がTypeScriptの可変長タプルパターン [infer F, ...infer Rest] を使って、デフォルト型を1つずつ剥がしていきます。

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"]

重要なのは次の点です。現在のデフォルト型 FIgnoredInputnullbooleanundefined など)であれば、再帰はそれをスキップして Rest に進みます。これは、ランタイムの if (!isPlainObject(defaults)) return _defu(baseObject, {}, ...) ——非オブジェクトは無視する——という振る舞いの型レベル版です。

ヒント: 可変長ジェネリック型を作るなら、[infer F, ...infer Rest] パターンが再帰の基本形です。無限再帰を避けるために、必ずベースケース(ここでは D extends [] が暗黙的に S を返す)を用意しましょう。

MergeObjects:キーごとの型マージ

これが中心的な処理です——ソースとデフォルトで共有されるすべてのキーを反復し、nullishを考慮したマージロジックを適用するmapped typeです。

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]>;
      };

各部分を順に見ていきましょう。

ショートサーキット:Destination extends Defaults ? Destination Destinationの型がすでにDefaultsのサブタイプ(すなわちDefaultsの制約をすべて満たしている)であれば、マージするものは何もありません。Destination をそのまま返します。不要な型計算を避けるための最適化です。

3つのintersection。 キーが重複しない場合の型は単純です。

  • Omit<Destination, keyof Destination & keyof Defaults> — Destinationにしかないキー
  • Omit<Defaults, keyof Destination & keyof Defaults> — Defaultsにしかないキー

重複するキー(keyof Destination & keyof Defaults)に対しては、mapped typeがnullishの判定ツリーを適用します。

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;"]

これはランタイムの振る舞いを忠実に再現しています。ソースの値が null/undefined ならデフォルトを使い、デフォルトが null/undefined ならソースを使い、どちらもnullishでなければ Merge を通じて再帰する——という流れです。-readonly 修飾子はキーから readonly を除去しています。これは、クローンされたオブジェクトがreadonly制約を失うというランタイムの挙動と一致しています。

Merge:型レベルのディスパッチチェーン

Merge は、_defu の内部判定ツリー——配列・関数・RegExp・Promise・プレーンオブジェクトを処理する部分——の型レベル版です。

src/types.ts#L77-L111

このチェーンは、DestinationとDefaultsを順番にチェックするネストした条件型です。

  1. Nullishディスパッチ(79〜84行目):どちらかがnullishなら、もう一方を返す(両方nullishなら nullish を返す)。
  2. 配列ディスパッチ(86〜89行目):両方が配列なら MergeArrays を使う。片方だけ配列なら、ユニオンを生成する。
  3. Function/RegExp/Promiseガード(92〜105行目):どちらかが Function、RegExp、または Promise であれば、Destination | Defaults のユニオンを生成する。deep mergeはしない。
  4. オブジェクト再帰(107〜111行目):両方が Input を継承していれば MergeObjects で再帰する。そうでなければユニオンを生成する。

Function/RegExp/Promiseのチェックは、ランタイムの isPlainObject ガードに対応しています。ランタイムでは isPlainObject(new Date())false を返すため、日付はdeep mergeされません。型レベルでは、DateFunction を継承することはありませんが、MergeObjects を発動させるような形で Input を継承しているわけでもないため、最後の Destination | Defaults ユニオンへ到達します。

チェックが重複しているのも注目です——まずDestination側(92〜97行目)、次にDefaults側(100〜105行目)で同じ確認が行われます。これは、どちらか一方がマージ不可能であれば深い再帰を防ぐ必要があるためです。たとえば { a: () => void }{ a: { nested: true } } とマージした場合、結果の型はマージされたオブジェクトではなく (() => void) | { nested: true } になります。

MergeArrays:配列結合の型モデリング

配列の型は、すっきりとシンプルです。

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;

ランタイムでは _defu が配列を結合します:[...value, ...object[key]]。結果の配列は両方のソースの要素を含みます。TypeScriptはこれを Array<DestinationType | SourceType> でモデル化します——要素の型は両配列の要素型のユニオンです。

test/defu.test.ts#L42-L52 のテストがこれを検証しています。

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 }
  >;
}>();

ランタイムの配列は [item1, item2] です。型は Array<{name: string; age: number} | {name: string; age: string}> になります。要素の順序や数は型システムには分かりませんが、どちらのソースの要素も含みうるということは正確に表現されています。

ランタイム ↔ 型の対応:並べて比較する

_defu のランタイム決定と型レベルの実装の完全な対応を以下にまとめます。

_defu のランタイム決定 型レベルの対応物 場所
if (!isPlainObject(defaults)) — 非オブジェクトをスキップ DefuF extends IgnoredInput ? Defu<S, Rest> : S types.ts L44-L47
value === null || value === undefined — nullishをスキップ MergeObjectsDestination[Key] extends nullish ? Defaults[Key] types.ts L27-L30
Array.isArray(value) && Array.isArray(object[key]) — 結合 MergeDestination extends Array<any> ? ... MergeArrays types.ts L86-L89
isPlainObject(value) && isPlainObject(object[key]) — 再帰 MergeDestination extends Input ? Defaults extends Input ? MergeObjects types.ts L107-L109
それ以外 — ソース値で上書き Merge 全体を通じた Destination | Defaults(ユニオンへのフォールバック) types.ts L89, L93, etc.
Object.assign({}, defaults) — defaultsをクローン MergeObjectsOmit<Defaults, shared keys> — Defaults専用キーが引き継がれる types.ts L25
arguments_.reduce(...) — 左畳み込み Defu<MergeObjects<S, F>, Rest> — タプルを再帰的に剥がす types.ts L42

意図的な相違点が1つあります。上書きのケースです。ランタイムでは、ソースにnullishでない値があり、デフォルトにも別の型のnullishでない値(たとえば関数と数値)がある場合、ソースが優先されます。しかし型レベルでは、ランタイムの値が分からないため Destination | Defaults のユニオンを生成します。これは健全な選択です。「どちらの可能性もある」と型が伝え、実際の値へのナローイングはランタイムに委ねます。

ヒント: ランタイムの振る舞いを型でモデル化するとき、どちらの分岐に進むかが型システムから分からないケースにはユニオンが最善です。ユニオンは常に健全ですが、片方を決め打ちした型はランタイムが別の方向に進んだ場合に不健全になります。

DefuInstanceインターフェース

公開されている defu エクスポートは単なる関数ではありません。fnarrayFnextend をプロパティとして持つ DefuInstance です。

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;
}

呼び出しシグネチャでは sourceSource | IgnoredInput を許容しています。第一引数として nullundefined を渡してもきちんと動作します。ランタイムが処理し、Defu が型を正しく扱います。Defaults extends Array<Input | IgnoredInput> の rest パラメータ ...defaultsDefu<S, D> を支える可変長タプル推論を可能にしています。

expectTypeOfによる型レベルテスト

Defuのテストスイートはランタイムの値だけでなく、型もテストしています。マージ結果を生成する it() ブロックはすべて、expect-type ライブラリの expectTypeOf を使って推論された型をアサートしています。

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 }>();

より複雑なシナリオとして、複数デフォルトの推論(test/defu.test.ts#L92-L104)、undefined ソースを含む部分マージ(test/defu.test.ts#L124-L164)、インターフェースベースの設定マージもテストされています。

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>();

expectTypeOf アサーションはランタイムでは実行されません——純粋にコンパイル時のチェックです。だからこそ tsc --noEmit が独立したCIステップとして存在しています(第1回で触れた通りです)。Vitestが expect() アサーションを実行し、TypeScriptが expectTypeOf() アサーションを検証します。両方がパスして初めて完成です。

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

この二重検証のアプローチは、defuのようなライブラリにとって欠かせません。型のリグレッション——ランタイムは正しく動いているのに推論型が any や過度に広い型になってしまうケース——は、下流のすべての利用者の型チェックをサイレントに壊します。型を明示的にテストすることで、defuはそうしたリグレッションをリリース前に検出できます。

シリーズのまとめ

この3回の記事を通じて、defuのアーキテクチャの骨格からアルゴリズムの核心、そして型レベルの頭脳まで追いかけてきました。このライブラリは、約100行のランタイムコードにどれほど多くの設計が詰め込めるかを示す好例です。

  • 第1回では、ファクトリパターン、CJS/ESMデュアルパブリッシング、型をファーストクラスとして扱うCIパイプラインを見ました。
  • 第2回では、_defu の5方向の判定ツリー、isPlainObject ガード、プロトタイプ汚染の防止、そしてStrategy PatternによるMergerフックを追いました。
  • 第3回では、それらのランタイム決定の一つひとつが再帰的な条件型にどのように反映され、下流の利用者まで正確なマージ型として流れ込むかを明らかにしました。

より広い視点から見たアーキテクチャ上の教訓はこうです。ランタイムロジックに明確な判定分岐があるなら、型システムでも同じ分岐をモデル化できる(そしてすべきである)、ということです。Defuの112行の型ファイルは偶然生まれた複雑さではありません。設定オブジェクトが深くネストされ、引数が可変長で、しばしば部分的にしか定義されないフレームワークのエコシステムにおいて、型安全なdeep defaultsを実現するための対価です。Nuxtのユーザーがマージされた設定に対して正確なオートコンプリートを得るたびに、その投資は報われています。