ファントム型
型パラメーターが値レベルでは使われず、コンパイル時の状態追跡にのみ使用される型テクニック
ファントム型とは
ファントム型 (Phantom Type) は、型パラメーターが実際の値には使われず、コンパイル時の状態追跡にのみ使用される型テクニックである。「この値はバリデーション済み」「この接続は認証済み」といった状態を型レベルで表現し、不正な状態遷移をコンパイル時に防ぐ。
Haskell や Rust で広く使われてきた手法で、TypeScript でもジェネリクスを活用して実現できる。ブランド型がプリミティブの区別に特化しているのに対し、ファントム型はオブジェクトの状態遷移の安全性に焦点を当てる。
基本的な実装
TypeScript でファントム型を成立させる要点は、状態を表す型パラメーターに構造上の居場所を与えることだ。宣言しただけでクラスのどのメンバーにも登場させないと、構造的部分型のもとで Article<Draft> と Article<Published> は同じ型と見なされ、this 型注釈による遷移の制約が一切働かない。そこで declare を付けたプライベートフィールドとして型パラメーターを保持する。
// 状態を表す型 (値としては使われない = ファントム)
type Draft = { readonly _state: 'draft' };
type Published = { readonly _state: 'published' };
type Archived = { readonly _state: 'archived' };
class Article<State> {
// 型パラメータの居場所。declare なので JavaScript には何も出力されない
declare private readonly _phantom: State;
private constructor(
readonly title: string,
readonly body: string,
) {}
static create(title: string, body: string): Article<Draft> {
return new Article(title, body) as Article<Draft>;
}
// Draft → Published のみ許可
publish(this: Article<Draft>): Article<Published> {
return new Article(this.title, this.body) as unknown as Article<Published>;
}
// Published → Archived のみ許可
archive(this: Article<Published>): Article<Archived> {
return new Article(this.title, this.body) as unknown as Article<Archived>;
}
}
const draft = Article.create('Hello', 'World');
const published = draft.publish(); // ✅ Draft → Published
const archived = published.archive(); // ✅ Published → Archived
// draft.archive(); // ❌ Draft → Archived は不可 (error TS2684)
// published.publish(); // ❌ Published → Published は不可 (error TS2684)
State 型パラメーターは実行時には存在しない。コンパイラーだけがこれを見て、draft.archive() を「'Article' の this コンテキストはメソッドの this ('Article') に代入できない」という TS2684 として弾く。
注意すべきは、_phantom フィールドを取り除くと同じコードがエラーなく通ってしまう点だ (TypeScript 6.0 で確認)。型パラメーターが型の構造に現れない限り、コンパイラーはそれを型の比較材料にしない。ファントム型を「型パラメーターを宣言するだけの手法」と理解していると、コンパイルが通ったことを型安全の証拠と取り違える。
実務での活用パターン
DB 接続の状態管理
type Open = { readonly _: 'open' };
type Closed = { readonly _: 'closed' };
class Connection<State> {
declare private readonly _phantom: State;
private constructor(private pool: Pool) {}
static async connect(config: PoolConfig): Promise<Connection<Open>> {
const pool = new Pool(config);
await pool.connect();
return new Connection(pool) as unknown as Connection<Open>;
}
// Open な接続でのみクエリ実行可能
async query(this: Connection<Open>, sql: string): Promise<QueryResult> {
return this.pool.query(sql);
}
// Open → Closed
async close(this: Connection<Open>): Promise<Connection<Closed>> {
await this.pool.end();
return new Connection(this.pool) as unknown as Connection<Closed>;
}
}
const conn = await Connection.connect(config);
await conn.query('SELECT 1'); // ✅ Open なので OK
const closed = await conn.close();
// await closed.query('SELECT 1'); // ❌ Closed では不可 (error TS2684)
リクエストパイプライン
type Raw = { readonly _: 'raw' };
type Authenticated = { readonly _: 'authenticated' };
type Authorized = { readonly _: 'authorized' };
class ApiRequest<State> {
declare private readonly _phantom: State;
constructor(readonly headers: Record<string, string>, readonly userId?: string) {}
authenticate(this: ApiRequest<Raw>): ApiRequest<Authenticated> | null {
const token = this.headers['authorization'];
if (!token) return null;
const userId = verifyToken(token);
return new ApiRequest({ ...this.headers }, userId) as unknown as ApiRequest<Authenticated>;
}
authorize(this: ApiRequest<Authenticated>, permission: string): ApiRequest<Authorized> | null {
if (!hasPermission(this.userId!, permission)) return null;
return this as unknown as ApiRequest<Authorized>;
}
}
function handleAdmin(req: ApiRequest<Authorized>) { /* 安全 */ }
認証を通していない ApiRequest<Raw> に authorize を呼ぶと TS2684 で弾かれるため、認証から認可への順序がコンパイル時に固定される。ここでクラス名を ApiRequest にしているのは実務上の都合で、Request は DOM の組み込み型と同名のため、モジュールになっていないファイルで宣言すると識別子の重複エラーになる。
ブランド型との使い分け
| 手法 | 対象 | 型パラメーターの扱い | 用途 |
|---|---|---|---|
| ブランド型 | プリミティブ (string, number) | 交差型でタグを混ぜる | UserId と OrderId の区別 |
| ファントム型 | オブジェクト | 状態を表す型をメンバーに保持する | 状態遷移の安全性 (Draft → Published) |
ブランド型は「値の種類」を区別し、ファントム型は「値の状態」を追跡する。両者は補完的な関係にあり、どちらも実行時コストはゼロで、Haskell や Rust で育った発想を TypeScript の構造的型システムに載せたものだという点も共通する。
ランタイムコストゼロ
ファントム型パラメーターは JavaScript にトランスパイルされると完全に消える。型チェックはコンパイル時のみで、実行時のオーバーヘッドはゼロだ。
ただしゼロで済むかは書き方に依存する。型パラメーターの居場所を declare private readonly _phantom: State; と書けば宣言はコンパイル時に消えるが、private readonly _phantom!: State; と定義代入アサーションで書くと、useDefineForClassFields が有効な設定 (ES2022 以降を対象にした場合の既定) ではフィールド定義そのものが出力され、インスタンスごとに値が undefined のプロパティが 1 つ増える。実行時コストゼロという売りを守るなら declare 側を選ぶ。
理論と実装の両面から学ぶなら関連書籍が参考になる。
この記事は役に立ちましたか?
関連用語
TypeScript
JavaScript に型を書く手段を足した言語。構造的型付け、型が実行時に残らない仕組み、消せない構文と型定義ファイルの扱いを解説
ブランド型
TypeScript で構造的に同じ型を名目的に区別し、型の取り違えを防ぐテクニック
ジェネリクス
型をパラメーター化し、型安全性を保ちながら汎用的なコードを書く仕組み
コンパイラー
人間が書いたソースコードを機械が実行できる形式に一括変換するプログラム
JIT コンパイル
プログラムの実行時にコードをネイティブコードにコンパイルし、パフォーマンスを向上させる技術
型の絞り込み
TypeScript の型ガードや制御フロー分析で、ユニオン型をより具体的な型に絞り込む手法