type-system-patterns
リポジトリ: vitejs/vite 分析日: 2026-02-17
概要
Vite 8 の型設計を分析した。78k stars のビルドツールとして、プラグイン API・設定・HMR という3つの主要ドメインで複雑な型を管理している。特筆すべきは、公開型定義(types/)と内部型定義(src/types/)を明確に分離し、@internal JSDoc + stripInternal + カスタムビルドパイプラインで公開 API の表面積を制御している点、および isolatedDeclarations: true を採用して型生成の高速化と明示性を両立している点である。条件型・module augmentation・satisfies never による網羅性チェックなど、大規模 TypeScript プロジェクトの型設計プラクティスが凝縮されている。
背景にある原則
- 公開 API の表面積を最小化すべき。内部型の漏洩はメジャーバージョン間の互換性維持コストを増大させるため: Vite は
@internalJSDoc +compilerOptions.stripInternalでビルド時に内部型を除去し、さらにrolldown.dts.config.tsのpatchTypesプラグインで TypeScript が取りこぼした@internalパラメータ等も追加で除去している(rolldown.dts.config.ts:354-378) - 型の正確性はビルドパイプラインで機械的に検証すべき。人間のレビューに依存すると漏れが生じるため: 型バンドル後に
tsconfig.check.jsonでmoduleResolution: "node16"+exactOptionalPropertyTypes: trueによる厳格な消費者視点チェックを実行している(tsconfig.check.json:1-18) - devDependencies の型がランタイムバンドルに混入してはならない。消費者がインストールしていない型への参照は解決不能エラーを引き起こすため:
src/types/ディレクトリに devDependencies の型をインラインし、#dep-types/*パスマッピングで内部参照する戦略を取っている(package.json:39) - 条件型と
satisfiesを組み合わせて、コンパイル時に不変条件を保証すべき。ランタイムエラーより型エラーのほうが修正コストが低いため: 網羅性チェック、設定デフォルト値の型安全性、プラグインフック互換性テストなど、随所でsatisfiesが活用されている
実例と分析
3層の型エクスポート戦略
Vite は型を3つのレイヤーに分離している:
- 公開型 (
types/): ユーザーがvite/types/*として参照する.d.tsファイル。package.jsonのexportsで"./types/*": { "types": "./types/*" }としてエクスポートし、内部サブパスは"./types/internal/*": nullで遮断している。 - 内部型 (
src/types/): devDependencies の型をインラインした.d.tsファイル。#dep-types/*パスマッピングで内部参照する。消費者には一切公開されない。 - ソース型 (
src/node/*.ts): 実装と同居する型定義。src/node/index.tsのexport type { ... }で公開するものだけを選別している。
// packages/vite/package.json:33-40
"imports": {
"#types/*": "./types/*.d.ts",
"#dep-types/*": "./src/types/*.d.ts"
},// packages/vite/src/node/index.ts:270-286 — dep-types の再エクスポート
export type { Alias, AliasOptions, MapToFunction, ResolverFunction, ResolverObject } from "#dep-types/alias";
export type { Connect } from "#dep-types/connect";
export type { WebSocket, WebSocketAlias } from "#dep-types/ws";@internal による公開 API 制御
@internal JSDoc タグをフィールド・パラメータ単位で付与し、stripInternal: true で型生成時に除去する。TypeScript の stripInternal が対応しない箇所(関数パラメータ内の @internal コメント、union 型の一部メンバー等)は、型バンドル後に Babel AST を解析して追加除去する。
// packages/vite/src/node/server/moduleGraph.ts:49-50
/** @internal */
lastHMRInvalidationReceived = false;// packages/vite/rolldown.dts.config.ts:384-404
function removeInternal(s: MagicString, node: any): boolean {
if (
node.leadingComments
&& node.leadingComments.some((c: any) => {
return c.type === "CommentBlock" && c.value.includes("@internal");
})
) {
// strip trailing comma or pipe
const trailingRe = /\s*[,|]/y;
trailingRe.lastIndex = node.end;
const trailingStr = trailingRe.exec(s.original)?.[0] ?? "";
s.remove(node.leadingComments[0].start, node.end + trailingStr.length);
return true;
}
return false;
}Module Augmentation による外部型の拡張
Vite は declare module を3つの目的で使っている:
- Rolldown プラグインコンテキストの拡張: Vite 固有の
environmentプロパティを Rolldown のMinimalPluginContextに注入する。これにより、すべてのプラグインフックでthis.environmentにアクセスできるようになる。
// packages/vite/src/node/plugin.ts:86-89
declare module "rolldown" {
export interface MinimalPluginContext extends PluginContextExtension {}
export interface PluginContextMeta extends PluginContextMetaExtension {}
}- Rolldown 出力メタデータの拡張:
OutputAssetやRenderedChunkにviteMetadataを追加する。
// packages/vite/types/metadata.d.ts:32-47
declare module "rolldown" {
export interface OutputAsset {
viteMetadata?: AssetMetadata;
}
export interface RenderedChunk {
viteMetadata?: ChunkMetadata;
}
}- 型なし依存関係への型付与:
connect,cors,postcss-import等の型なしパッケージに対してsrc/types/shims.d.tsで宣言する。
条件型による型推論パターン
InferCustomEventPayload<T> は、既知イベント名にはマップから型を引き、未知イベント名には any にフォールバックする。
// packages/vite/types/customEvent.d.ts:46-47
export type InferCustomEventPayload<T extends string> = T extends keyof CustomEventMap ? CustomEventMap[T] : any;HookHandler<T> は ObjectHook<H> ラッパーから内部ハンドラ型を抽出する。
// packages/vite/src/node/plugin.ts:373
export type HookHandler<T> = T extends ObjectHook<infer H> ? H : T;ImportGlobFunction はジェネリクスと条件型で eager フラグに応じて同期/非同期の戻り値型を切り替える。
// packages/vite/types/importGlob.d.ts:61-70
<
Eager extends boolean,
As extends string,
T = As extends keyof KnownAsTypeMap ? KnownAsTypeMap[As] : unknown,
>(
glob: string | string[],
options?: ImportGlobOptions<Eager, As>,
): (Eager extends true ? true : false) extends true
? Record<string, T>
: Record<string, () => Promise<T>>ViteTypeOptions: ユーザー拡張可能な型スイッチ
importMeta.d.ts で空インターフェース ViteTypeOptions を定義し、ユーザーが augmentation で strictImportMetaEnv キーを追加すると ImportMetaEnv の挙動が変わる仕組み。
// packages/vite/types/importMeta.d.ts:7-14
interface ViteTypeOptions {}
type ImportMetaEnvFallbackKey = "strictImportMetaEnv" extends keyof ViteTypeOptions ? never : string;
interface ImportMetaEnv extends Record<ImportMetaEnvFallbackKey, any> {
BASE_URL: string;
MODE: string;
DEV: boolean;
PROD: boolean;
SSR: boolean;
}これにより、デフォルトでは import.meta.env.ANY_KEY が any で通り、strictImportMetaEnv を有効化すると未定義のキーがエラーになる。型テストも専用ファイルで検証されている(src/node/__tests_dts__/typeOptions.ts)。
satisfies never による網羅性チェック
switch 文の default ケースで satisfies never を使い、すべての分岐が網羅されていることをコンパイル時に保証する。新しい列挙値が追加された場合、対応する分岐がなければ型エラーになる。
// packages/vite/src/node/build.ts:1119-1121
default:
logLeveling satisfies never
// fallback to info if a unknown log level is passed// packages/vite/src/node/plugins/css.ts:3278-3279
default:
throw new Error(`Unknown lang: ${lang satisfies never}`)Resolved 型パターン: UserConfig → ResolvedConfig
設定オブジェクトを「ユーザー入力型(すべてオプショナル)」と「解決済み型(必須フィールドが確定)」に分離し、Omit + intersection で差分を表現する。
// packages/vite/src/node/config.ts:264-272
export type ResolvedDevEnvironmentOptions =
& Omit<
Required<DevEnvironmentOptions>,
"sourcemapIgnoreList"
>
& {
sourcemapIgnoreList: Exclude<
DevEnvironmentOptions["sourcemapIgnoreList"],
false | undefined
>;
};// packages/vite/src/node/config.ts:635-740 (抜粋)
export interface ResolvedConfig extends Readonly<
Omit<UserConfig, 'plugins' | 'css' | 'json' | ...> & {
root: string
base: string
plugins: readonly Plugin[]
// ...
}
> {}string & {} による文字列リテラル + 任意文字列の両立
// packages/vite/types/customEvent.d.ts:52
export type CustomEventName = keyof CustomEventMap | (string & {});// packages/vite/src/node/server/index.ts:301
environments: Record<"client" | "ssr" | (string & {}), DevEnvironment>;string & {} により、IDE の自動補完で既知のリテラル候補を提示しつつ、任意の文字列も受け付ける。
型レベルでの深い再帰マージ
MergeWithDefaultsResult は再帰的条件型でデフォルト値とオーバーライド値のマージ結果型を正確に推論する。
// packages/vite/src/node/utils.ts:1176-1195
type MergeWithDefaultsResult<D, V> = Equal<D, undefined> extends true ? V
: D extends Function | Array<any> ? MaybeFallback<D, V>
: V extends Function | Array<any> ? MaybeFallback<D, V>
: D extends Record<string, any> ? V extends Record<string, any> ? {
[K in keyof D | keyof V]: K extends keyof D ? K extends keyof V ? MergeWithDefaultsResult<D[K], V[K]>
: D[K]
: K extends keyof V ? V[K]
: never;
}
: MaybeFallback<D, V>
: MaybeFallback<D, V>;パターンカタログ
Adapter パターン (構造)
- 解決する問題: Rolldown のプラグインインターフェースを Vite 固有のフック(
hotUpdate,configureServer等)で拡張しつつ、Rolldown プラグインとの互換性を維持する - 適用条件: 上流ライブラリの型を拡張しつつ、上流型との代入互換を保ちたい場合
- コード例:
src/node/plugin.ts:101—Plugin<A> extends RolldownPlugin<A>+ module augmentation - 注意点: augmentation が上流の型更新で壊れる可能性がある。型テスト(
__tests_dts__/plugin.ts)でExpectExtendsを使って互換性を継続検証している
- 解決する問題: Rolldown のプラグインインターフェースを Vite 固有のフック(
Type State パターン (振る舞い)
- 解決する問題:
UserConfig(未解決状態)とResolvedConfig(解決済み状態)を型レベルで区別し、未解決設定を解決済みとして使うミスを防ぐ - 適用条件: オブジェクトが「未確定→確定」の状態遷移を持つ場合
- コード例:
src/node/config.ts:339UserConfig→src/node/config.ts:635ResolvedConfig - 注意点:
Omit+ intersection の組み合わせが複雑になりやすいため、ユーティリティ型(RequiredExceptFor等)で分解する
- 解決する問題:
Good Patterns
satisfiesによるデフォルト値の型安全性:Object.freeze({...} satisfies UserConfig)により、デフォルト値オブジェクトがインターフェースに適合することをコンパイル時に保証しつつ、リテラル型を保持する。
// packages/vite/src/node/config.ts:775-883
const configDefaults = Object.freeze(
{
// ... フィールド群
} satisfies UserConfig,
);as const satisfies Record<K, V>による型安全な定数マップ: リテラル型の推論と Record 型の制約を両立する。
// packages/vite/src/node/build.ts:1573-1579
const customRelativeUrlMechanisms = {
...relativeUrlMechanisms,
'worker-iife': (relativePath) => getResolveUrl(...),
} as const satisfies Record<string, (relativePath: string) => string>- keyof ベースの型安全ジェネリック getter/setter:
ModuleNode._get<T extends keyof EnvironmentModuleNode>(prop: T)で、プロパティ名から戻り値型を自動推論する。
// packages/vite/src/node/server/mixedModuleGraph.ts:33-37
_get<T extends keyof EnvironmentModuleNode>(
prop: T,
): EnvironmentModuleNode[T] {
return (this._clientModule?.[prop] ?? this._ssrModule?.[prop])!
}consistent-type-importsESLint ルール:import typeを強制することで、型インポートがランタイムバンドルに含まれないことを保証する。
// eslint.config.js:154-157
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', disallowTypeAnnotations: false },
],Anti-Patterns / 注意点
Omitの連鎖による可読性低下:ResolvedConfigはOmit<UserConfig, 10+ keys> & { ... }という巨大な intersection 型になっており、IDE のホバー表示が読みにくい。
// Bad: 大量の Omit キー
export interface ResolvedConfig extends
Readonly<
Omit<
UserConfig,
| "plugins"
| "css"
| "json"
| "assetsInclude"
| "optimizeDeps"
| "worker"
| "build"
| "dev"
| "environments"
| "experimental"
| "future"
| "server"
| "preview"
| "devtools"
> & {/* 30+ フィールド */}
>
{}// Better: 共通部分をベース型として分離し、未解決/解決済みで個別に拡張する
interface ConfigBase {
root?: string;
base?: string;
mode?: string;
}
interface UserConfig extends ConfigBase {
plugins?: PluginOption[]; /* optional */
}
interface ResolvedConfig extends Required<ConfigBase> {
plugins: readonly Plugin[]; /* required */
}mergeConfigの引数にFunctionを渡す罠: ランタイムでthrowするが、型レベルではD extends Function ? never : Dで防いでいる。この制約型は呼び出し側で見落とされやすい。
// packages/vite/src/node/utils.ts:1420-1421
defaults: D extends Function ? never : D,
overrides: O extends Function ? never : O,導出ルール
[MUST]公開型と内部型を物理的に分離し、内部型は公開 API に漏洩させない- 根拠: Vite は
types/(公開)、src/types/(devDeps インライン)、src/node/(ソース型)の3層に分離し、package.jsonのexportsとimportsで境界を制御している
- 根拠: Vite は
[MUST]@internalタグ付きメンバーがビルド成果物に残らないことを CI で検証する- 根拠: Vite は
stripInternal+ カスタム AST パッチ +tsconfig.check.jsonによる消費者視点チェックの3段階で検証している
- 根拠: Vite は
[SHOULD]switch 文の網羅性チェックにsatisfies neverを使い、列挙値追加時の分岐漏れをコンパイル時に検出する- 根拠: Vite は
build.ts:1120、config.ts:2158、css.ts:3279、oxc.ts:68など4箇所以上でsatisfies neverを使用している
- 根拠: Vite は
[SHOULD]ユーザー入力型(オプショナル)と解決済み型(必須)を明示的に分離し、Required<T>/Omit+ intersection で変換する- 根拠:
UserConfig→ResolvedConfig、DevEnvironmentOptions→ResolvedDevEnvironmentOptionsなど、Vite 全体で一貫して適用されている
- 根拠:
[SHOULD]デフォルト値オブジェクトにはsatisfies InterfaceNameを付与し、インターフェースとの乖離をコンパイル時に検出する- 根拠:
configDefaults(config.ts:883)、buildEnvironmentOptionsDefaults(build.ts:413)、ssrConfigDefaults(ssr/index.ts:62)等で一貫使用
- 根拠:
[SHOULD]devDependencies の型を公開 API に含める必要がある場合は、型定義をインラインし内部パスマッピングで参照する- 根拠:
src/types/alias.d.ts等でインラインし、#dep-types/*マッピングで参照。消費者が devDeps をインストールせずに型チェックが通る
- 根拠:
[SHOULD]文字列リテラルの自動補完と任意文字列の受容を両立するには'known' | (string & {})パターンを使う- 根拠:
CustomEventNameやenvironmentsの Record キー型で採用されている
- 根拠:
[AVOID]Omitのキーを10個以上列挙する型定義。可読性が著しく低下し、キーの追加漏れリスクが増す- 根拠:
ResolvedConfigは13個のキーをOmitしており、型定義の見通しが悪くなっている
- 根拠:
適用チェックリスト
- [ ] 公開型定義と内部型定義が物理的に分離されているか確認する
- [ ]
package.jsonのexports/importsで内部型のサブパスが遮断されているか確認する - [ ]
@internalタグ付きメンバーがビルド成果物から除去されることをテストしているか確認する - [ ] ビルド済み型を消費者の
moduleResolution設定(node16等)でチェックしているか確認する - [ ] switch 文に
satisfies neverによる網羅性チェックが入っているか確認する - [ ] デフォルト値オブジェクトに
satisfiesが付与されているか確認する - [ ]
import typeの強制ルール(ESLintconsistent-type-imports)が設定されているか確認する - [ ] 文字列リテラルユニオンで任意文字列も受け付けたい箇所に
(string & {})を使っているか確認する