Read OSS

Defuの再帰マージを解剖する:アルゴリズム、セキュリティ、そして拡張性

中級

前提知識

  • 第1回:DefuのアーキテクチャとAPI設計
  • JavaScriptオブジェクトの基礎:Object.assign、プロトタイプチェーン、Object.getPrototypeOf
  • 再帰アルゴリズムの理解
  • プロトタイプ汚染というセキュリティリスクへの認識

Defuの再帰マージを解剖する:アルゴリズム、セキュリティ、そして拡張性

第1回では、defuのアーキテクチャ全体を俯瞰しました。ファクトリーパターン、5つの公開API、CJS/ESMデュアル配信の戦略です。今回はいよいよエンジンの中身に踏み込みます。_defu 関数はおよそ40行のコードですが、その1行1行にオブジェクトのマージ方針が凝縮されています。セキュリティ(プロトタイプ汚染)、セマンティクス(nullishスキップ、配列の連結)、拡張性(mergerフック)、そして正確性(isPlainObject ガード)——すべての分岐を順を追って読み解いていきましょう。

_defu関数:コードを1行ずつ読む

アルゴリズムの全体像はこちらです:

src/defu.ts#L5-L47

関数シグネチャを見ると、引数の役割がわかります。baseObject は優先度の高いソース、defaults は低優先度のフォールバック、namespace はコンテキスト依存のマージに使うオプションの文字列、merger はカスタムロジックのためのオプションコールバックです。

アルゴリズムは baseObject の各キーに対して、5段階の判定ツリーを順に辿ります:

flowchart TD
    Start["for key in baseObject"] --> Security{"key === '__proto__'<br/>or 'constructor'?"}
    Security -->|Yes| Skip1[continue — skip key]
    Security -->|No| Nullish{"value === null<br/>or undefined?"}
    Nullish -->|Yes| Skip2["continue — keep default"]
    Nullish -->|No| Merger{"merger callback<br/>returns true?"}
    Merger -->|Yes| Skip3["continue — merger handled it"]
    Merger -->|No| ArrayCheck{"Both arrays?"}
    ArrayCheck -->|Yes| Concat["Concatenate:<br/>[...value, ...default]"]
    ArrayCheck -->|No| ObjectCheck{"Both plain objects?"}
    ObjectCheck -->|Yes| Recurse["_defu(value, default,<br/>namespace, merger)"]
    ObjectCheck -->|No| Override["object[key] = value"]

チェックの順序には意味があります。セキュリティが最優先——危険なキーは値を見る前にスキップします。次にnullish判定——ソースの値が nullundefined のときは「意見なし、デフォルトを使え」という意味になります。3番目にmergerフック——組み込みロジックより前に、nullishでない値を処理する機会を与えます。これらをくぐり抜けて初めて、配列・オブジェクト・プリミティブの振り分けに到達します。

クローンのセマンティクス:なぜObject.assign({}, defaults)なのか

15行目は見落としがちですが、非常に重要です:

const object = Object.assign({}, defaults);

これは defaultsシャロークローンを出力オブジェクトの初期値として生成しています。その後、baseObject のキーを順に走査してこのクローンを更新していきます。この設計には2つの重要な意味があります:

  1. どちらの入力も変更されない。 出力は常に新しいオブジェクトです。ネストされたオブジェクトが再帰的にマージされる場合も、そのレベルで新しいオブジェクトが生成されます。

  2. デフォルト優先、ソースで上書き。 デフォルトのクローンを起点にしてソースの値で更新するため、デフォルトにしか存在しないキーは自動的に残ります。for...in ループが走査するのは baseObject の列挙可能なキーだけなので、デフォルトのキーが削除されることはありません。

これは Object.assign(target, source) の動作とは逆の発想です。Object.assign では第2引数が第1引数を上書きします。一方 _defu では、第1引数(baseObject/ソース)が高優先度でありながら、第2引数(defaults)のコピーを更新するという形をとります。「デフォルトをすべて持った状態から始め、ユーザーが明示的に指定した部分だけを上書きする」というモデルです。

プロトタイプ汚染の防止

18〜20行目がセキュリティガードです:

if (key === "__proto__" || key === "constructor") {
  continue;
}

プロトタイプ汚染とは、{"__proto__": {"isAdmin": true}} のような悪意ある入力をオブジェクトにマージすることで Object.prototype を改ざんし、ランタイム上のすべてのオブジェクトに影響を与える攻撃手法です。再帰的にプロパティを代入するディープマージユーティリティは、この攻撃の格好の標的になります。

defuの対策はシンプルです——該当するキーを黙ってスキップするだけです。テストスイートでもこの動作を明示的に検証しています:

test/defu.test.ts#L106-L115

it("should not override Object prototype", () => {
  const payload = JSON.parse(
    '{"constructor": {"prototype": {"isAdmin": true}}}',
  );
  defu({}, payload);
  defu(payload, {});
  defu(payload, payload);
  expect({}.isAdmin).toBe(undefined);
});

このテストは、悪意あるペイロードをソースとして渡す場合、デフォルトとして渡す場合、両方に渡す場合の3パターンをカバーしています。いずれの場合も {}.isAdminundefined のままでなければなりません。

ヒント: 自前でディープマージユーティリティを書く場合は、必ず __proto__constructor キーのガードを入れましょう。プロトタイプ汚染対策の最低限として必須です。defu のように明示的に実装しているライブラリは少なくなく、自前実装ではこの対策が抜け落ちるケースが多いです。

isPlainObjectガード

isPlainObject 関数は、ある値を再帰的にディープマージすべきか、それとも不透明なリーフとして扱うかを判断するゲートキーパーです。このガードがなければ、defuはDate、RegExp、クラスインスタンス、Map、Setなどにも喜んで再帰してしまい、それらの内部プロパティをプレーンオブジェクトに展開してしまいます。

src/_utils.ts#L1-L26

この関数は4つのチェックを順番に実行します:

flowchart TD
    A["isPlainObject(value)"] --> B{"value === null or<br/>typeof !== 'object'?"}
    B -->|Yes| R1[return false]
    B -->|No| C{"prototype !== null<br/>AND !== Object.prototype<br/>AND grandparent !== null?"}
    C -->|Yes| R2["return false<br/>(class instance, Error, etc.)"]
    C -->|No| D{"Symbol.iterator<br/>in value?"}
    D -->|Yes| R3["return false<br/>(Array, Set, Map, etc.)"]
    D -->|No| E{"Symbol.toStringTag<br/>in value?"}
    E -->|Yes| F{"toString === '[object Module]'?"}
    F -->|Yes| R4["return true<br/>(ES Module namespace)"]
    F -->|No| R5["return false<br/>(Math, Promise, etc.)"]
    E -->|No| R6[return true]

チェック1:nullと非オブジェクト。 プリミティブと null はプレーンオブジェクトではありません。

チェック2:プロトタイプチェーン。 プレーンオブジェクトのプロトタイプは、Object.prototype{}new Object() で生成)か nullObject.create(null) で生成)のどちらかです。それ以外——クラスインスタンス、ErrorDateRegExp——はプロトタイプチェーンがより深くなります。Object.getPrototypeOf(prototype) !== null というチェックは、プロトタイプが null で「祖父母」を持たない Object.create(null) オブジェクトを区別するためのものです。

チェック3:Symbol.iterator。 配列、Set、Map、TypedArray、arguments オブジェクトなど、反復可能なものをすべて弾きます。これらはプロトタイプチェーン上に Symbol.iterator を持っています。

チェック4:Symbol.toStringTag。 多くの組み込みオブジェクトは Symbol.toStringTag を持っています(Math[object Math]Promise[object Promise] など)。これらはすべて弾かれます——ただし、[object Module] を持つESモジュールのnamespaceオブジェクトだけは例外です。import * as config from './defaults' の結果として得られるnamespaceオブジェクトはマージ可能であるべきだからです。この例外は重要です。

テストスイートはこれらのエッジケースを網羅的に検証しています:

test/utils.test.ts#L1-L49

{ [Symbol.toStringTag]: true }false を返し(46行目)、{ [Symbol.iterator]: true }false を返します(47行目)——自身のプロパティとしてこれらのシンボルを持つプレーンオブジェクトでさえ弾かれます。これは意図的に保守的な設計です。反復可能オブジェクトやタグ付きオブジェクトに見えるものを誤ってディープマージしてしまうリスクを防いでいます。

MergerによるStrategyパターン

第1回で説明したとおり、createDefu はオプションの merger コールバックを受け取ります。_defu の内部では、このコールバックが28行目で呼び出されます:

if (merger && merger(object, key, value, namespace)) {
  continue;
}

契約はシンプルです。mergerは現在の出力オブジェクト、処理中のキー、ソースの値、そしてドット区切りのnamespaceを受け取ります。truthy値を返せば、_defu はそのキーに対する組み込みロジックをスキップします。falsy(または undefined)を返せば、標準の配列・オブジェクト・プリミティブ処理に移行します。

これはStrategyパターンの最もコンパクトな実装といえます。defuFndefuArrayFn がどのようにこれを活用しているか見てみましょう:

src/defu.ts#L61-L74

// defuFn: if the source value is a function, call it with the default
export const defuFn = createDefu((object, key, currentValue) => {
  if (object[key] !== undefined && typeof currentValue === "function") {
    object[key] = currentValue(object[key]);
    return true;
  }
});

// defuArrayFn: same, but only when the default is an array
export const defuArrayFn = createDefu((object, key, currentValue) => {
  if (Array.isArray(object[key]) && typeof currentValue === "function") {
    object[key] = currentValue(object[key]);
    return true;
  }
});

違いは微妙ですが重要です。defuFnソースが関数を提供し、かつデフォルトに何らかの定義済みの値があれば関数mergerを呼び出します。defuArrayFn はより限定的で、デフォルトが配列である場合にのみ関数を呼び出します。どちらも条件にマッチしない関数は標準のマージロジックにフォールバックし、そこではプレーンな値として扱われます。

namespace パラメータを使うと、コンテキストに応じたマージが実現できます。このテストを見てください:

test/defu.test.ts#L218-L235

const ext = createDefu((obj, key, val, namespace) => {
  if (key === "modules") {
    obj[key] = namespace + ":" + [...val, ...obj[key]].sort().join(",");
    return true;
  }
});

const obj1 = { modules: ["A"], foo: { bar: { modules: ["X"] } } };
const obj2 = { modules: ["B"], foo: { bar: { modules: ["Y"] } } };
expect(ext(obj1, obj2)).toEqual({
  modules: ":A,B",
  foo: { bar: { modules: "foo.bar:X,Y" } },
});

トップレベルの namespace"" なので ":A,B" になり、ネストした foo.bar レベルでは "foo.bar" となって "foo.bar:X,Y" が生成されます。同じキー名がネストの深さによって異なる意味を持つような設定システムでは、このnamespaceを使ってmergerの挙動を切り替えることができます。

reduceによる複数引数の畳み込み

第1回でも触れたように、createDefuArray.reduce を使って複数の引数を畳み込みます:

src/defu.ts#L50-L54

return (...arguments_) =>
  arguments_.reduce((p, c) => _defu(p, c, "", merger), {} as any);

defu({ a: 1 }, { b: 2, a: 'x' }, { c: 3, a: 'x', b: 'x' }) がどのように処理されるかを見てみましょう:

sequenceDiagram
    participant R as reduce accumulator
    participant S as {a: 1}
    participant D1 as {b: 2, a: 'x'}
    participant D2 as {c: 3, a: 'x', b: 'x'}

    R->>S: _defu({}, {a:1}) → {a: 1}
    Note right of S: Start with empty, apply source
    S->>D1: _defu({a:1}, {b:2, a:'x'}) → {a:1, b:2}
    Note right of D1: a:1 wins over a:'x', b:2 fills gap
    D1->>D2: _defu({a:1,b:2}, {c:3,a:'x',b:'x'}) → {a:1,b:2,c:3}
    Note right of D2: a and b already set, c:3 fills gap

初期アキュムレータの {} がポイントです。最初の _defu 呼び出しでは {a: 1} をデフォルトとしてクローンし、ソースとして {} を適用します(キーがないので何も変わらない)。結果は実質的に {a: 1} のクローンです。以降の呼び出しでは、積み上がった結果と次のデフォルトオブジェクトをマージしていきます。_defu は常に Object.assign({}, defaults) から始まるため、高優先度の値は常に勝ち続けます。

test/defu.test.ts#L92-L104 のテストでは、実行時の結果と推論された型の両方を検証しています:

const result = defu({ a: 1 }, { b: 2, a: "x" }, { c: 3, a: "x", b: "x" });
expect(result).toEqual({ a: 1, b: 2, c: 3 });
expectTypeOf(result).toMatchTypeOf<{
  a: string | number;
  b: string | number;
  c: number;
}>();

a の実行時の値は 1(数値)ですが、型は string | number です。型システムはどちらのパスが実行時に勝つかを知ることができないため、a はどちらの型にもなりえると判断します。この型レベルの挙動については第3回で詳しく掘り下げます。

ESモジュールnamespaceとカスタムmergerのテスト

特に興味深いエッジケースが、ESモジュールのnamespaceオブジェクト(import * as foo from '...' の結果)のマージです。これらのオブジェクトは null プロトタイプを持ち、Symbol.toStringTag"Module" に設定されています。isPlainObject に例外処理がなければ、defuはこれらをディープマージしようとしません。

テストのフィクスチャはシンプルです:

test/fixtures/index.ts#L1test/fixtures/nested.ts からデフォルトをre-exportしています:

// index.ts
export { default as exp } from "./nested.js";

// nested.ts
export default { nested: 1 };

テストではnamespace全体をインポートしてマージします:

test/defu.test.ts#L237-L252

import * as asteriskImport from "./fixtures/";

it("works with asterisk-import", () => {
  expect(
    defu(asteriskImport, { a: 2, exp: { anotherNested: 2 } }),
  ).toStrictEqual({
    a: 2,
    exp: { anotherNested: 2, nested: 1 },
  });
});

asteriskImport はESモジュールのnamespaceです。[object Module] の例外処理があるおかげで、isPlainObject はこれに対して true を返します。exp プロパティはプレーンオブジェクト { nested: 1 } であり、{ anotherNested: 2 } とディープマージされます。Symbol.toStringTag のチェックがなければ、namespaceオブジェクトはリーフとして扱われ、exp へのディープマージは行われません。

ヒント: import()import * を使ったconfig loaderを作る場合は、マージユーティリティがESモジュールのnamespaceを正しく扱えるか確認しましょう。defuは対応していますが、他の多くのライブラリは対応していません。

次回予告

これで _defu のすべての実行時判定を追い終わりました。上部のセキュリティガードから、下部の配列・オブジェクト・プリミティブの振り分けまで、ランタイムの動作は完全に把握できました。しかし、まだ触れていない実装が1つあります。src/types.ts に約112行にわたる型システムです。これは今回追ったランタイムの各判定を、再帰的な条件型として型レベルで完全に再現しています。第3回では、この型レベルのディープマージを解剖します。Defu<S, D> がvariadic tupleの引数をどう処理するか、MergeObjects がnullishを意識したキーのマージをどう実現するか、そして Merge が今回追ったのと同じ判定ツリーをどのように型レベルで走査するかを詳しく見ていきます。