design-philosophy
リポジトリ: unjs/unbuild 分析日: 2026-02-16
概要
unbuild の設計思想を分析する。ゼロコンフィグ推論・unjs エコシステムによる機能分解・hook ベースの拡張性・セルフホスティングの 4 つの軸から、DX を最優先にしたビルドツール設計のプラクティスを抽出する。小規模なコードベース(43 ファイル)にもかかわらず、rollup・mkdist・untyped・copy の 4 ビルダーを統合し、package.json だけで ESM/CJS/DTS のデュアルパブリッシュを実現する仕組みは、「設定より規約」アプローチの優れた実装例である。
背景にある原則
- Convention over Configuration(規約による推論): ビルド設定を明示的に書かせず、package.json の
exports・main・module・types・binフィールドから自動的にエントリポイント・出力フォーマット・型定義生成の要否を推論する(src/auto.ts:69-169)。ユーザーが「何をビルドするか」ではなく「何をパブリッシュするか」を宣言すれば、ビルドはそこから逆算される。 - Composable Ecosystem(小さなツールの合成): 単一の巨大ツールを作るのではなく、各関心事を独立パッケージ(pathe, consola, defu, hookable, citty, mlly, pkg-types, jiti, mkdist, untyped)に分解し、それらを組み合わせて機能を構築する。unbuild 自体はこれらのオーケストレーターとして振る舞い、各機能の実装詳細を外部に委譲している。
- Safe Defaults with Escape Hatches(安全なデフォルト + 脱出口):
failOnWarn: true・clean: true・externalsに全 builtinModules を含むなど、安全側に倒したデフォルトを提供しつつ、build.config.tsや hook システムで任意のカスタマイズを許容する(src/build.ts:98-175)。 - Dogfooding by Self-Hosting(セルフホスティングによる自己検証): unbuild は自身を unbuild でビルドする(
package.jsonの"build": "pnpm unbuild"、build.config.tsでdefineBuildConfigを使用)。これによりツール自体がユーザーと同じ体験を強制的に経験し、設計上の問題を早期に発見できる。
実例と分析
package.json からの自動推論
auto.ts の inferEntries 関数は、package.json のフィールドを入力として受け取り、ビルドエントリを逆算する。この「出力から入力を推論する」アプローチが unbuild のゼロコンフィグの核心である。
推論のステップ:
exports・bin・main・module・typesから出力ファイル一覧を抽出(src/auto.ts:80-97)- 出力ファイルの拡張子と
typeフィールドから ESM/CJS を判別(src/auto.ts:100-108) - 出力パスからソースファイルを逆引きし、エントリポイントを特定(
src/auto.ts:114-166) .d.ts出力があれば型定義生成を有効化(src/auto.ts:158-159)
この設計により、zero-config の例(examples/1.zero-config/)では build.config.ts が存在せず、package.json の exports フィールドだけでビルドが完結する。
defu によるレイヤードマージ
設定の優先順位を defu(deep defaults utility)で実現している。defu は「先に定義された値を優先する」セマンティクスを持つため、引数の順序がそのまま優先順位になる:
buildConfig > pkg.unbuild > inputConfig > preset > hardcoded defaultsこれにより、ユーザーの明示的な設定 > package.json 内の設定 > CLI 引数 > プリセット > デフォルト値という自然な優先順位が宣言的に表現される。
hook システムによるプラグインアーキテクチャ
全 4 ビルダーが統一的な hook インターフェースを持つ。hook は粒度の異なる 3 レイヤーで提供される:
- ライフサイクル hook:
build:prepare→build:before→build:done - ビルダー固有 hook:
rollup:options→rollup:build→rollup:dts:options→rollup:dts:build→rollup:done - エントリ単位 hook:
mkdist:entry:options→mkdist:entry:build、untyped:entry:options→untyped:entry:schema→untyped:entry:outputs
hook の登録は preset → inputConfig → buildConfig の順で行われ(src/build.ts:195-203)、後から登録した hook が後に実行される。これにより、プリセットのデフォルト動作をユーザー設定で上書きできる。
ビルダーのプラグイン有効/無効パターン
Rollup プラグインの設定で false を渡すとプラグイン自体が無効化されるパターンを採用している:
// src/builders/rollup/config.ts:114-166
plugins: [
ctx.options.rollup.replace && replace({ ... }),
ctx.options.rollup.esbuild && esbuild({ ... }),
ctx.options.rollup.commonjs && commonjs({ ... }),
].filter((p): p is NonNullable<Exclude<typeof p, false>> => !!p),型定義でもこのパターンが反映されている(src/builders/rollup/types.ts:65-100)。各プラグインオプションは PluginOptions | false 型で定義され、false で明示的に無効化できる。
コード例
// src/auto.ts:17-61 — autoPreset: package.json からの自動推論をフックとして実装
export const autoPreset: BuildPreset = definePreset(() => {
return {
hooks: {
"build:prepare"(ctx): void {
// Disable auto if entries already provided or pkg not available
if (!ctx.pkg || ctx.options.entries.length > 0) {
return;
}
const sourceFiles = listRecursively(join(ctx.options.rootDir, "src"));
const res = inferEntries(ctx.pkg, sourceFiles, ctx.options.rootDir);
for (const message of res.warnings) {
warn(ctx, message);
}
ctx.options.entries.push(...res.entries);
// ...
},
},
};
});// src/build.ts:98-175 — defu によるレイヤードコンフィグマージ
const options = defu(
buildConfig,
pkg.unbuild || pkg.build,
inputConfig,
preset,
{
name: (pkg?.name || "").split("/").pop() || "default",
rootDir,
entries: [],
clean: true,
declaration: undefined,
outDir: "dist",
stub: _stubMode,
// ...
failOnWarn: true,
rollup: {
emitCJS: false,
// ...
esbuild: { target: "esnext" },
},
} satisfies BuildOptions,
) as BuildOptions;// src/types.ts:204-208 — defineBuildConfig: 型安全なヘルパー関数
export function defineBuildConfig(
config: BuildConfig | BuildConfig[],
): BuildConfig[] {
return (Array.isArray(config) ? config : [config]).filter(Boolean);
}// src/validate.ts:8-47 — ビルド後の依存関係検証
export function validateDependencies(ctx: BuildContext): void {
const usedDependencies = new Set<string>();
const unusedDependencies = new Set<string>(
Object.keys(ctx.pkg.dependencies || {}),
);
const implicitDependencies = new Set<string>();
// ... 使用された import を追跡し、未使用・暗黙依存を警告
}// build.config.ts:1-12 — セルフホスティング: unbuild 自身のビルド設定
import { defineBuildConfig } from "./src";
export default defineBuildConfig({
hooks: {
async "build:done"() {
await rm("dist/index.d.ts");
await rm("dist/cli.d.ts");
await rm("dist/cli.d.mts");
},
},
});パターンカタログ
Strategy パターン (分類: 振る舞い)
- 解決する問題: 異なるビルド方式(rollup/mkdist/untyped/copy)を統一的に扱う
- 適用条件: 同一インターフェースで異なるアルゴリズムを切り替える場面
- コード例:
src/build.ts:293-298—buildTasks配列に各ビルダーを格納し、順次またはパラレルに実行 - 注意点: 各ビルダーは
(ctx: BuildContext) => Promise<void>という統一シグネチャを持つが、内部でentry.builderフィールドによりフィルタリングする自己選択式
Observer パターン (分類: 振る舞い)
- 解決する問題: ビルドプロセスの各段階をユーザーが監視・介入できるようにする
- 適用条件: プロセスの拡張ポイントを外部に公開したい場面
- コード例:
src/build.ts:191-206—hookableによる hook 登録、各ビルダーでのcallHook呼び出し - 注意点: hook は async 対応。
hookableは unjs 独自の軽量実装で、EventEmitter より型安全
Template Method パターン (分類: 振る舞い)
- 解決する問題: ビルドの骨格(prepare → before → build → done)を固定し、各ステップの実装を差し替え可能にする
- 適用条件: アルゴリズムの構造は共通だが、個々のステップが異なる場面
- コード例:
src/build.ts:206-398—build:prepare→build:before→ builder 実行 → validate →build:done
Good Patterns
- define ヘルパーによる型安全な設定:
defineBuildConfigとdefinePresetは実行時にはほぼ何もしない(配列化と filter のみ)が、IDE 上での型補完とバリデーションを提供する。この「型のためだけの関数」パターンにより、JSON や plain object では得られない開発体験を実現する。
// src/types.ts:204-208
export function defineBuildConfig(
config: BuildConfig | BuildConfig[],
): BuildConfig[] {
return (Array.isArray(config) ? config : [config]).filter(Boolean);
}- satisfies による型安全なデフォルト値:
asキャストではなくsatisfies演算子を使い、デフォルト値オブジェクトがBuildOptionsの構造に合致することをコンパイル時に検証している。defuのマージ結果は型推論が困難なため、入力側で型安全性を確保するアプローチ。
// src/build.ts:174
} satisfies BuildOptions,- ビルド後の自動検証: ビルド完了後に
validateDependenciesとvalidatePackageを実行し、未使用依存・暗黙依存・package.json で宣言されたファイルの欠損を検出する(src/build.ts:394-395)。failOnWarn: trueがデフォルトのため、CI で問題を早期に発見できる。
// src/build.ts:402-414
if (ctx.warnings.size > 0) {
consola.warn(
"Build is done with some warnings:\n\n"
+ [...ctx.warnings].map((msg) => "- " + msg).join("\n"),
);
if (ctx.options.failOnWarn) {
consola.error(
"Exiting with code (1). You can change this behavior by setting `failOnWarn: false` .",
);
process.exit(1);
}
}- スタブモードによる開発体験の高速化:
unbuild --stubで dist に jiti ベースの JIT コンパイルスタブを生成し、ソースコードの変更が即座に反映される。ビルド→テストのフィードバックループを除去する発想。
// src/builders/rollup/stub.ts:141-163 (MJS スタブ生成の核心部分)
`import { createJiti } from ${JSON.stringify(jitiESMPath)};`,
// ...
`const jiti = createJiti(import.meta.url, ${serializedJitiOptions})`,
`const _module = await jiti.import(${JSON.stringify(resolvedEntry)});`,Anti-Patterns / 注意点
- 暗黙のインライン化による予期しないバンドルサイズ膨張: externals にも dependencies にも含まれないモジュールは暗黙的にバンドルにインラインされ、警告のみ出力される。これは安全側(ランタイムエラーを防ぐ)だが、意図しないバンドルサイズの増大を招く可能性がある。
// Bad: 依存関係を package.json に宣言し忘れると暗黙インライン
// src/builders/rollup/config.ts:103-104
warn(ctx, `Implicitly bundling "${originalId}"`);
return false; // external ではなくインラインする
// Better: 依存関係は必ず package.json に明示し、
// failOnWarn: true(デフォルト)で CI を止めるasキャストによる型安全性の喪失:defuのマージ結果をas BuildOptionsでキャストしている箇所がある。defuの戻り値型が deep merge の正確な型を推論できないための妥協だが、ランタイムエラーのリスクがある。
// Bad: defu の結果を as でキャスト
// src/build.ts:175
) as BuildOptions;
// Better: defu の結果を runtime validation (Zod 等) で検証する
// ただし unbuild のケースではパフォーマンスとのトレードオフ導出ルール
[MUST]ツールのデフォルト設定は「安全側」に倒す(failOnWarn: true、clean: true、builtinModules の自動 external 化)。ユーザーが明示的にオプトアウトする設計にすることで、暗黙の不具合を防ぐ- 根拠: unbuild は
failOnWarn: trueをデフォルトとし、未使用依存・暗黙インライン・出力ファイル欠損を CI で検出する(src/build.ts:136,src/build.ts:402-414)
- 根拠: unbuild は
[MUST]設定ファイルにはdefineXxx形式の型安全ヘルパー関数を提供する。実行時ロジックは最小限にし、主目的は IDE の型補完と入力検証とする- 根拠:
defineBuildConfigは配列化と filter のみ行い、型推論だけで開発者の設定ミスを防止する(src/types.ts:204-208)
- 根拠:
[SHOULD]設定のマージは「明示的な優先順位チェーン」で行い、deep merge ユーティリティ(defu 等)を使って宣言的に表現する。条件分岐による手続き的なマージを避ける- 根拠: unbuild は
defu(buildConfig, pkg.unbuild, inputConfig, preset, defaults)の引数順序で優先順位を表現し、複雑な if 文を排除している(src/build.ts:98-175)
- 根拠: unbuild は
[SHOULD]ゼロコンフィグを実現する場合、「出力仕様(マニフェスト)から入力を逆算する」アプローチを採る。ユーザーが宣言するのは「何を公開するか」であり、「どうビルドするか」はツールが推論する- 根拠: unbuild は package.json の
exports/main/module/typesからエントリポイントとフォーマットを自動推論する(src/auto.ts:69-169)
- 根拠: unbuild は package.json の
[SHOULD]ビルドツール・開発ツールは自身のツールで自身をビルドする(セルフホスティング)。ユーザーと同じ体験を自ら経験することで、DX 上の問題を早期発見できる- 根拠: unbuild は
build.config.tsでdefineBuildConfigを使い、pnpm unbuildで自身をビルドする(package.json:20,build.config.ts)
- 根拠: unbuild は
[SHOULD]プラグインの有効/無効は、オプション型をPluginOptions | falseとし、falseで明示的に無効化する API にする。プラグインが未設定の場合はデフォルトで有効にする- 根拠: Rollup プラグイン群は全て
options | falseの型定義を持ち、falseが渡された場合は.filter(Boolean)で除外される(src/builders/rollup/types.ts:65-100,src/builders/rollup/config.ts:114-166)
- 根拠: Rollup プラグイン群は全て
[AVOID]ビルドプロセスの拡張を「設定オプションの追加」だけで対応しようとすること。hook/plugin システムを提供し、想定外のカスタマイズにも対応できる拡張ポイントを残す- 根拠: unbuild は 20 以上の hook ポイント(build:prepare, rollup:options, mkdist:entry:options 等)を提供し、設定だけでは対応できないカスタマイズを可能にしている(
src/types.ts:197-202, 各ビルダーの types.ts)
- 根拠: unbuild は 20 以上の hook ポイント(build:prepare, rollup:options, mkdist:entry:options 等)を提供し、設定だけでは対応できないカスタマイズを可能にしている(
適用チェックリスト
- [ ] ツールのデフォルト設定を「安全側」に倒しているか?(厳格なバリデーション、自動クリーンアップ等がデフォルトで有効か)
- [ ] 設定ファイルに
defineXxx形式の型安全ヘルパーを提供しているか?(IDE 補完だけで設定ミスを防げるか) - [ ] 設定のマージに明示的な優先順位チェーンを使っているか?(条件分岐の手続き的マージになっていないか)
- [ ] ゼロコンフィグが可能な場面で、出力仕様から入力を逆算する推論ロジックを実装しているか?
- [ ] 自分のツール/ライブラリを自分自身で使ってビルド・テストしているか?(セルフホスティング)
- [ ] プラグイン・拡張機能に
options | falseパターンを採用し、無効化の API を提供しているか? - [ ] 設定オプションだけでなく、hook/plugin による拡張ポイントを提供しているか?
- [ ] エコシステムの機能を単一ツールに埋め込まず、独立パッケージに分解して合成しているか?