ファントム型
型パラメータが値レベルでは使われず、コンパイル時の状態追跡にのみ使用される型テクニック
TypeScript型システム
ファントム型とは
ファントム型 (Phantom Type) は、型パラメータが実際の値には使われず、コンパイル時の状態追跡にのみ使用される型テクニックである。「この値はバリデーション済み」「この接続は認証済み」といった状態を型レベルで表現し、不正な状態遷移をコンパイル時に防ぐ。
Haskell や Rust で広く使われてきた手法で、TypeScript でもジェネリクスを活用して実現できる。ブランド型がプリミティブの区別に特化しているのに対し、ファントム型はオブジェクトの状態遷移の安全性に焦点を当てる。
基本的な実装
// 状態を表す型 (値としては使われない = ファントム)
type Draft = { readonly _state: 'draft' };
type Published = { readonly _state: 'published' };
type Archived = { readonly _state: 'archived' };
class Article<State> {
private constructor(
readonly title: string,
readonly body: string,
) {}
static create(title: string, body: string): Article<Draft> {
return new Article(title, body);
}
// 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 は不可
// published.publish(); // ❌ コンパイルエラー! Published → Published は不可
State 型パラメータは実行時には存在しない。TypeScript のコンパイラだけが認識し、不正な状態遷移を防ぐ。
実務での活用パターン
DB 接続の状態管理
type Open = { readonly _: 'open' };
type Closed = { readonly _: 'closed' };
class Connection<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 では不可
リクエストパイプライン
type Raw = { readonly _: 'raw' };
type Authenticated = { readonly _: 'authenticated' };
type Authorized = { readonly _: 'authorized' };
class Request<State> {
constructor(readonly headers: Record<string, string>, readonly userId?: string) {}
authenticate(this: Request<Raw>): Request<Authenticated> | null {
const token = this.headers['authorization'];
if (!token) return null;
const userId = verifyToken(token);
return new Request({ ...this.headers }, userId) as unknown as Request<Authenticated>;
}
authorize(this: Request<Authenticated>, permission: string): Request<Authorized> | null {
if (!hasPermission(this.userId!, permission)) return null;
return this as unknown as Request<Authorized>;
}
}
// 認証 → 認可の順序が型で強制される
function handleAdmin(req: Request<Authorized>) { /* 安全 */ }
ブランド型との使い分け
| 手法 | 対象 | 用途 |
|---|---|---|
| ブランド型 | プリミティブ (string, number) | UserId と OrderId の区別 |
| ファントム型 | オブジェクト | 状態遷移の安全性 (Draft → Published) |
ブランド型は「値の種類」を区別し、ファントム型は「値の状態」を追跡する。両者は補完的な関係にある。
ランタイムコストゼロ
ファントム型パラメータは JavaScript にトランスパイルされると完全に消える。型チェックはコンパイル時のみで、実行時のオーバーヘッドはゼロだ。
ファントム型 vs Branded 型
| 観点 | ファントム型 | Branded 型 |
|---|---|---|
| 型パラメータ | 未使用 (幽霊) | intersection |
| ランタイムコスト | ゼロ | ゼロ |
| 言語 | Haskell, Rust | TypeScript |
理論と実装の両面から学ぶなら関連書籍が参考になる。