ファントム型

型パラメータが値レベルでは使われず、コンパイル時の状態追跡にのみ使用される型テクニック

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

理論と実装の両面から学ぶなら関連書籍が参考になる。

関連用語