大規模モノレポにおける型定義の要塞設計:Interface vs Type Alias のコンパイラ最適化とメモリ戦略
TypeScriptの型システムは、開発時には極めて強力なセーフティネットとして機能するが、数万ファイル規模のモノレポや数百のパッケージが錯綜する共有ライブラリ環境においては、型定義そのものがコンパイラ(TSServer)のメモリを圧迫し、ビルドパイプラインを崩壊させる「目に見えないボトルネック」に変貌する。
ネット上の凡百の記事では「拡張できるからInterface」「ユニオンだからType」という表層的な構文の違いばかりが語られる。しかし、シニアアーキテクトが直面するのは、コンパイラの型評価フェーズ(Type Instantiation)におけるメモリ消費量、`d.ts` 出力時のシンボルテーブル肥大化、そして IDE の LSP(Language Server Protocol)が消費するヒープ領域の限界である。
本稿では、インターフェースと型エイリアスの内部挙動の差をコンパイラレベルで解剖し、大規模チームが破綻なくスケールするための型定義管理戦略を提示する。
—
1. コンパイラ内部における Interface と Type Alias の決定的な違い
TypeScriptの型チェッカー(Type Checker)は、型をメモリ上に保持する際、それぞれの構造を内部表現(`ObjectType`, `UnionType`, `IntersectionType` など)に変換してキャッシュする。ここでの挙動の差が、大規模コードベースのパフォーマンスを大きく左右する。
Interface:名前空間付きキャッシュと宣言併合(Declaration Merging)
`interface` は、TypeScriptコンパイラにおいて 「単一の名前付きオブジェクト型シンボル」 として扱われる。
同一スコープ内で同名の `interface` が複数定義された場合、コンパイラは即座にシンボルテーブル上でこれらをマージする(Declaration Merging)。
- メモリ効率: 高い。コンパイラは元のプロパティを再計算せず、単一のオブジェクト構造体として参照を保持する。
- IDEの親和性: LSPはホバー時やオートコンプリート時に、そのシンボルをキャッシュから高速に引くことができる。
Type Alias:遅延評価なきインスタンス化と交差型(Intersection)の罠
`type` は、右辺に記述された式の 「評価結果へのエイリアス(別名)」 である。特に大罪なのは、`&`(交差型)を用いたオブジェクトの拡張である。
// 危険なアンチパターン:巨大なモノレポで多用される交差型
type BaseUser = { id: string; name: string; };
type UserPermissions = { permissions: string[]; };
type UserProfile = { bio: string; };
// これがネストまたは大量に連鎖すると、コンパイラの型評価器は悲鳴を上げる
type FatUser = BaseUser & UserPermissions & UserProfile;
コンパイラは `FatUser` を評価する際、単一のオブジェクトとしてキャッシュせず、交差されたすべての型のプロパティを遅延なく、あるいは無駄に再評価・結合しようとする(Instantiation)。
これが数百ファイルに連鎖すると、TypeScriptの型チェッカーは無限ループに近い構造解析に陥り、Node.jsプロセスのヒープメモリ(`–max-old-space-size`)を食いつぶす。
—
2. 実践:モノレポにおける境界防御と型インフラストラクチャ
共有パッケージ(例: `@company/types`)を設計する際、我々アーキテクトは「どのように型が消費されるか」を逆算して宣言を選定しなければならない。
戦略 A: パブリックAPIの境界には必ず `interface` を採用する
外部に公開するドメインモデルやDTO(Data Transfer Object)は、コンパイラのキャッシュ効率を最大化するため `interface` で定義する。これにより、消費側パッケージでの型推論のオーバーヘッドを最小化できる。
// @company/types/src/domain.ts
// 境界(Boundary)となるドメインモデルは必ず interface にする
export interface EnterpriseUser {
readonly id: string;
email: string;
metadata: Record
}
// 拡張が必要な場合も、型エイリアスの & ではなく extends を使う
export interface AdminUser extends EnterpriseUser {
readonly securityClearanceLevel: number;
}
戦略 B: 厳密なユニオン型とプリミティブの制約には `type` を採用する
代数データタイプ(ADT)や、特定の文字列リテラル、条件付き型(Conditional Types)の演算においては、`type` が唯一の選択肢となる。
// @company/types/src/utils.ts
// プリミティブの絞り込みやユニオン、条件付き型は type エイリアスを使う
export type HttpMethod = ‘GET’ | ‘POST’ | ‘PUT’ | ‘DELETE’;
export type AsyncResult
| { success: true; data: T }
| { success: false; error: E };
—
3. 大規模チームのための型定義ガバナンス:ESLint と Compiler API の活用
ルールを口頭で伝えても、大規模チームでは必ず破られる。コンパイル速度の劣化を防ぐためには、機械的な防壁を構築する必要がある。
ESLint ルールによる交差型の制限
モノレポ全体で `type User = A & B` のようなオブジェクトの交差型を無制限に許可すると、徐々にコンパイルが重くなる。そのため、オブジェクト同士の交差型を静的に検出するカスタムルール、あるいは既存の `@typescript-eslint` の厳格な運用が求められる。
// .eslintrc.json の一例(概念設定)
{
“rules”: {
“@typescript-eslint/consistent-type-definitions”: [“error”, “interface”]
}
}
※注: すべてを interface に強制することはできないため、ドメインエンティティレイヤーにおいてはオブジェクトの `&` 結合を禁止するコードレビュー体制をCIに組み込むのが実務的である。
`d.ts` のバンドルとパフォーマンス
共有パッケージをビルドする際、`tsc –declaration` によって生成される `.d.ts` ファイルが肥大化すると、依存する下流パッケージのコンパイル時間(Incremental Compilationのキャッシュヒット率)に悪影響を及ぼす。
プロジェクト・リファレンス(Project References)を活用し、パッケージ間の依存関係を明確に分離すること。
// tsconfig.json (Root)
{
“compilerOptions”: {
“composite”: true,
“declarationMap”: true,
“incremental”: true
}
}
`composite: true` を有効にすることで、TypeScriptコンパイラは差分ビルド(Incremental Build)のキャッシュ効率を極限まで高め、変更のないパッケージの型再評価を完全にスキップする。
—
4. チーフアーキテクトからの提言
コードベースが成長するにつれて、型システムは単なる「補完のためのツール」から、「開発速度を支配するインフラストラクチャ」 へと変貌する。
1. ドメインモデルと外部公開API: 常に `interface` を選び、Declaration Merging とコンパイラキャッシュの恩恵を受けよ。
2. ユニオン、マッピング型、条件分岐: 表現力が必要なメタプログラミング領域では `type` を使え。ただし、オブジェクト同士の `&` による結合はメモリ爆発の引き金になるため最小限に抑えよ。
3. Project References: モノレポのスケール限界を突破するため、`composite: true` による型のモジュール化を徹底せよ。
言語の仕様とコンパイラの内部挙動を理解した者だけが、100万行を超えるコードベースでも秒速でフィードバックを返す優美なアーキテクチャを構築できる。感覚ではなく、物理法則(計算量とメモリ)に基づいた型設計を実践してほしい。