ファントム型
型パラメータが値レベルでは使われず、コンパイル時の状態追跡にのみ使用される型テクニック
ファントム型とは
ファントム型 (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 側を選ぶ。
理論と実装の両面から学ぶなら関連書籍が参考になる。
この記事は役に立ちましたか?
関連用語
関連する記事
読む前にパラパラめくるだけで理解度が上がる - 技術書の予習法
技術書を最初から順に読み始めると挫折しやすい。読む前に数分パラパラめくるだけで全体像がつかめ、本文で迷って戻る回数が減る理由とやり方を解説。
紙の本と電子書籍、初心者はどっちがいい?
紙の本と電子書籍、プログラミング初心者にはどちらが向いているのか。それぞれのメリットを比較して、自分に合った方を選ぶヒントを紹介します。
技術書ランキングの決定版はどれか - IT エンジニア本大賞の歴代大賞と言及数データで読む
技術書ランキングを探している人向けに、検証可能な 2 つのデータ (IT エンジニア本大賞の歴代大賞 2014-2026 年と、当サイトの Qiita / Zenn 言及数ランキング) を整理。歴代受賞作の一覧表と、ランキングを選書に活かす方法を解説します。