defineConfig 系ヘルパーは identity とは限らない — 剥がすとビルドは通るのに機能が静かに死ぬ
Vite の defineConfig に代表される「define なんとか」というヘルパー関数は、型推論のためだけの identity 関数(受け取ったオブジェクトをそのまま返す関数)であることが多いです。
// Vite の defineConfig は実質これだけ
export function defineConfig(config: UserConfig): UserConfig {
return config;
}この経験則があるため、フレームワークのプラグイン API にある definePlugin のようなヘルパーも「どうせ identity だろう」と思い込みやすい。そして、共有パッケージからフレームワーク依存を切り離したいときなどに、import { definePlugin } from "framework" を外して (d) => d 相当のインライン実装に置き換えたくなります。
本記事では、それをやるとビルドも型チェックもデプロイも全部成功するのに、機能だけが静かに死ぬケースがあること、そしてなぜ静的チェックでは検出できないのかを、実際に確認した CMS フレームワークのプラグイン機構を例にまとめます。
前提: 例として挙げる挙動は特定の CMS フレームワーク(ここでは some-cms と呼びます)のプラグイン機構で確認したものですが、「define* ヘルパーが実行時に検証・正規化を行う」パターン自体は、どのフレームワークでも起こりえます。
罠その1: grep で見つかる identity 実装が「本物」とは限らない
「definePlugin は identity か?」を確かめようとして node_modules を grep すると、こういうコードが見つかることがあります。
export const definePlugin = (d) => d;「ほら、やっぱり identity だ」と結論づけると間違えます。これは some-cms の CLI がプラグインのマニフェスト抽出用に一時生成するシム(本物の代わりに差し込まれる軽量スタブ)で、ランタイムで使われる実装ではありませんでした。
本物は別のバンドルチャンクにいて、中身はこうなっています(簡略化)。
function definePlugin(definition) {
// 1. id の形式検証(lowercase + ダッシュ、または @scope/name)
// 2. version の semver 検証
// 3. capability 名の検証
for (const cap of capabilities) {
if (!validCapabilities.has(cap)) throw new Error(`Invalid capability "${cap}"`);
}
// 4. capability の正規化(レガシー名 → 正準名)
const canonical = normalizeCapabilities(capabilities);
// 5. write 系 capability に対応する read 系を暗黙補完
if (canonical.includes("content:write") && !canonical.includes("content:read")) {
normalizedCapabilities.push("content:read");
}
// 6. hooks をプラグイン id と紐付けて解決
return { id, version, capabilities: normalizedCapabilities, hooks: resolveHooks(hooks, id), ... };
}identity どころか、バリデーションと正規化の塊です。
罠その2: capability の「改名マップ」は実行時にしか存在しない
上のステップ 4 が今回の主役です。some-cms は capability 名の世代交代を、define* ヘルパー内の改名マップで吸収していました。
const CAPABILITY_RENAMES = Object.freeze({
"read:content": "content:read",
"write:content": "content:write",
"email:provide": "hooks.email-transport:register",
// ...
});つまりプラグインが宣言している "email:provide" はレガシー別名で、ランタイムが実際に見ているのは正準名の "hooks.email-transport:register" です。この変換は definePlugin を通ったときにだけ行われます。
ここで definePlugin を identity に置き換えるとどうなるか。
- プラグインの capability はレガシー名
"email:provide"のまま登録される - ランタイムは正準名しか見ないので、「このプラグインはメール配送の capability を持っていない」と判断する
- メール配送フックが登録されない
- フォーム送信や認証メールなどの通知が、エラーひとつ出さずに送られなくなる
恐ろしいのは 4 です。フックが「登録されない」ことは例外ではなく、ただの不在です。呼ぶ側から見れば「配送先プロバイダがいないので何もしない」という正常系のパスを通るだけで、ログにも痕跡が残りません。
なぜ静的チェックで検出できないのか
この壊れ方は、普段頼っているチェックを全部すり抜けます。
- 型チェック: identity 実装でもオブジェクトの形は正しいまま。型はレガシー名も許容している(後方互換のため union に残っている)ので、エラーになりません。
- ビルド: import が解決でき、バンドルが生成できれば成功します。capability 名の意味論はビルドの関心事ではありません。
- デプロイ: 成果物が正常に配置されれば成功します。
- 隣接機能のテスト: たとえばフォーム送信の「保存」は別プラグインの責務なので、送信テストをしても保存は成功します。死んでいるのは通知だけです。
「値の変換が実行時にしか起きず、その変換の欠落が例外ではなく機能の不在として現れる」— この組み合わせが、緑のパイプラインの下で機能を殺します。
対策
① define* ヘルパーを剥がさない・自作しない
フレームワークが提供する define* ヘルパーは、identity に見えてもそのフレームワークのバージョンと結合した ABI の一部として扱うのが安全です。「依存を切りたい」という動機でインライン化するのは、正規化ロジックのフォークを自前で保守すると宣言するのと同じです。
② 依存バージョンの混在が動機なら、pnpm の injected dependencies を使う
define* を剥がしたくなる典型的な動機は「共有パッケージが peer のフレームワークを単一バージョンに解決してしまい、モノレポ内のバージョン混在で壊れる」ことです。これはヘルパーの除去ではなく、dependenciesMeta.<pkg>.injected: true でアプリごとに peer を独立解決させるのが正解です。詳しくは別記事「pnpm monorepo で共有パッケージを複数バージョンの peer に対応させる(injected dependencies)」にまとめています。
③ 通知・配送系は「実際に届いたか」をランタイムで検証する
この類の機能は、ビルドの成否と生死が連動しません。依存の更新やプラグイン構成の変更をしたら、本物のイベントを 1 回流して、届くところまで観測するのを習慣にするのが確実です。Cloudflare Workers なら `wrangler tail` で本番ログを流しながらテスト送信し、配送フックの発火ログを目視する、という手順で数分で済みます。
まとめ
- define* ヘルパーは identity 関数とは限らない。実行時の検証・正規化(capability の改名など)を担っていることがある。
- grep で見つけた identity 実装は、CLI が生成するシムかもしれない。実装を根拠に判断するなら、ランタイムで実際に使われるコードパスを確認する。
- 正規化の欠落は例外ではなく「フックの不在」として現れるため、型チェック・ビルド・デプロイの成功では検出できない。通知・配送系はランタイムで実際の到達を検証する。
- バージョン混在が動機なら、ヘルパーを剥がすのではなく injected dependencies で解決する。