Phantom TypeとGADTsで型安全なコードを実現する実践ガイド
Phantom Type(幽霊型)とGADTs(一般化代数的データ型)は、実行時コストゼロで不正な状態をコンパイル時に検出する型システムの高度なテクニックです。MLパイプラインで「テンソルの次元を取り違える」「未学習モデルで推論してしまう」といったサイレントなバグに悩まされた経験はないでしょうか。この記事では、TypeScript・Rust・Python・Haskellの4言語でPhantom TypeとGADTsを実装し、型レベルでバグを未然に防ぐ方法を解説します。
この記事でわかること
- Phantom Typeの基本概念と「なぜ幽霊と呼ばれるのか」の理解
- TypeScript(Branded Types)、Rust(PhantomData)、Python(mypy + Generic)での実装方法
- TypeStateパターンによる状態マシンの型安全な設計
- GADTsの仕組みと型安全なDSLインタプリタの構築方法
- MLパイプラインにおけるPhantom Typeの実用例(テンソル次元の型安全性)
対象読者
- 想定読者: AI/MLに精通し、型システムの高度な活用に興味があるエンジニア
-
必要な前提知識:
- Python、TypeScript、またはRustの基礎文法
- ジェネリクス(型パラメータ)の基本的な使い方
- 静的型チェック(mypy、tsc、rustc)の基本理解
結論・成果
Phantom Typeを導入することで、以下の効果が報告されています。
- テンソル次元の取り違えバグ: ランタイムで発見していたインデックス混同が、mypyの静的解析でコーディング時に100%検出可能に(typegeist論文)
- 不正な状態遷移の防止: 未認証のDB接続でクエリを実行する、未学習モデルで推論するといったバグがコンパイルエラーとして検出される
- ランタイムオーバーヘッド: ゼロ。型情報はコンパイル時に消去(erased)されるため、実行時のメモリ・速度への影響なし
- 学習コスト: 各言語で1〜2時間の学習で基本パターンを習得可能
Phantom Typeを理解する
「幽霊型」とは何か
Phantom Type(幽霊型)とは、型パラメータが実際のデータに使われず、型チェックのためだけに存在する型のことです。「幽霊」という名前は、「型(spirit)だけが存在し、値(body)は存在しない」ことに由来します。
通常のジェネリクスでは、型パラメータ T は実際のフィールドの型として使われます。
// 通常のジェネリクス: T は実際の値の型
type Box<T> = { value: T } // T が value のフィールド型
// Phantom Type: T は値に使われない(幽霊のように型だけ存在)
type Tagged<Tag> = { readonly value: string } // Tag はどこにも使われない
この「使われない型パラメータ」に意味的な情報(検証済み/未検証、メートル/フィート、学習済み/未学習など)をエンコードすることで、コンパイラが不正な操作を検出できるようになります。
なぜPhantom Typeが必要なのか
以下のMLパイプラインを考えてみましょう。
# Phantom Typeなし: バグがランタイムまで検出されない
def normalize(features: list[float]) -> list[float]:
mean = sum(features) / len(features)
return [(x - mean) for x in features]
def predict(model, features: list[float]) -> float:
return model.forward(features)
raw_features = [1.0, 2.0, 3.0]
# 正規化を忘れても型チェックが通ってしまう!
result = predict(model, raw_features) # バグだが実行できる
この問題は「正規化済みのデータ」と「未正規化のデータ」が同じ list[float] 型で表現されていることに起因します。Phantom Typeを使えば、この区別を型レベルで強制できます。
よくある間違い: 「バリデーション関数を呼べば済むのでは?」と考えがちですが、バリデーション関数の呼び出し漏れ自体がバグの原因です。Phantom Typeはバリデーション済みかどうかを型で表現するため、呼び出し漏れをコンパイル時に検出できます。
言語別にPhantom Typeを実装する
Phantom Typeの実装方法は言語の型システムによって異なります。ここでは、TypeScript・Rust・Pythonの3言語で実装パターンを見ていきましょう。
TypeScript: Branded Typesパターン
TypeScriptは構造的型付け(structural typing)を採用しているため、型パラメータが同じ構造を持つ場合、異なる型でも互換性があると見なされます。この問題を回避するために、Branded Types(ブランド型)というテクニックを使います。
// branded_types.ts
// ブランド型の定義: never型のフィールドで構造的互換性を破壊する
type Branded<T, Brand extends string> = T & { readonly [K in Brand]: never }
// ユーザーIDと商品IDを区別する
type UserId = Branded<number, '__UserId'>
type ProductId = Branded<number, '__ProductId'>
// スマートコンストラクタ: 型安全な値を生成する唯一の方法
function createUserId(id: number): UserId {
return id as UserId
}
function createProductId(id: number): ProductId {
return id as ProductId
}
// 関数は特定のID型のみ受け付ける
function getUser(id: UserId): void {
console.log(`Fetching user: ${id}`)
}
function getProduct(id: ProductId): void {
console.log(`Fetching product: ${id}`)
}
// 使用例
const userId = createUserId(42)
const productId = createProductId(100)
getUser(userId) // OK
getProduct(productId) // OK
// getUser(productId) // コンパイルエラー! ProductId は UserId に代入できない
// getUser(42) // コンパイルエラー! number は UserId に代入できない
なぜ never 型を使うのか: TypeScriptの構造的型付けでは、フィールドの構造が同じであれば互換性があると判定されます。never 型のフィールドを追加することで、UserId と ProductId は構造的に異なる型になり、混同がコンパイルエラーになります。Angularフレームワークも内部でこのパターンを採用しています(参考)。
制約: as によるキャストは型安全性を一部放棄しています。スマートコンストラクタを経由させることでキャストを一箇所に限定し、それ以外のコードでは型安全性を維持する設計です。
次に、フォームデータの検証状態をPhantom Typeで表現する例を見てみましょう。
// form_validation.ts
// 検証状態を表すブランド
type Validated = { readonly __validated: unique symbol }
type Unvalidated = { readonly __unvalidated: unique symbol }
// フォームデータ型: Stateパラメータがファントム(値に使われない)
type FormData<State> = {
readonly _state: State // 実行時には参照されないファントムフィールド
readonly email: string
readonly age: number
}
// 未検証データの生成
function createFormData(email: string, age: number): FormData<Unvalidated> {
return { _state: {} as Unvalidated, email, age }
}
// 検証関数: Unvalidated → Validated への変換
function validate(
data: FormData<Unvalidated>
): FormData<Validated> | null {
if (!data.email.includes('@') || data.age < 0 || data.age > 150) {
return null
}
return { ...data, _state: {} as Validated }
}
// 送信関数: Validated のみ受け付ける
function submit(data: FormData<Validated>): void {
console.log(`Submitting: ${data.email}`)
}
// 使用例
const raw = createFormData('user@example.com', 25)
// submit(raw) // コンパイルエラー! Unvalidated は受け付けない
const valid = validate(raw)
if (valid) {
submit(valid) // OK: 検証済みデータのみ送信可能
}
Rust: PhantomDataパターン
Rustでは標準ライブラリの std::marker::PhantomData<T> を使ってPhantom Typeを実装します。PhantomData<T> はサイズゼロの型で、コンパイラに「この構造体は型 T を論理的に所有している」ことを伝えます。
// measurement.rs
use std::marker::PhantomData;
// 単位を表すマーカー型(フィールドなし = データなし)
struct Meters;
struct Feet;
// 計測値: 単位情報はPhantomDataで型レベルにのみ存在
#[derive(Debug, Clone, Copy)]
struct Measurement<Unit> {
value: f64,
_unit: PhantomData<Unit>, // サイズゼロ、ランタイムコストなし
}
impl<Unit> Measurement<Unit> {
fn new(value: f64) -> Self {
Measurement {
value,
_unit: PhantomData,
}
}
}
// メートル同士の加算のみ許可
impl std::ops::Add for Measurement<Meters> {
type Output = Measurement<Meters>;
fn add(self, rhs: Self) -> Self::Output {
Measurement::new(self.value + rhs.value)
}
}
// 単位変換関数: Feet → Meters
fn feet_to_meters(feet: Measurement<Feet>) -> Measurement<Meters> {
Measurement::new(feet.value * 0.3048)
}
fn main() {
let height_m = Measurement::<Meters>::new(1.8);
let width_m = Measurement::<Meters>::new(3.0);
let total = height_m + width_m; // OK: メートル + メートル
println!("Total: {:?}", total);
let height_ft = Measurement::<Feet>::new(5.9);
// let invalid = height_m + height_ft; // コンパイルエラー! Meters + Feet は未定義
let converted = feet_to_meters(height_ft);
let valid = height_m + converted; // OK: 変換後は加算可能
println!("Valid: {:?}", valid);
}
なぜRustでは PhantomData が必要か: Rustのコンパイラは未使用の型パラメータを許可しません(unused type parameter エラー)。PhantomData<T> を使うことで、型パラメータ T が「使用されている」とコンパイラに認識させます。PhantomData 自体はサイズゼロのため、構造体のメモリレイアウトに影響しません(Rust By Example公式ドキュメント)。
注意:
PhantomData<T>はTに対するドロップチェック(drop check)にも影響します。PhantomData<T>を含む構造体は、Tをドロップする責任があるかのように扱われます。参照のライフタイムを扱う場合はPhantomData<&'a T>の形式を使い、ライフタイムの制約を正しく伝える必要があります。
Python: mypy + Genericパターン
Pythonは動的型付け言語ですが、型ヒントとmypy(またはpyright)を組み合わせることで、Phantom Typeによる静的型チェックを実現できます。
# phantom_ml.py
from typing import Generic, TypeVar
# MLパイプラインの状態を表すマーカークラス
class Raw:
"""未処理の生データ"""
pass
class Normalized:
"""正規化済みデータ"""
pass
class Augmented:
"""データ拡張済み"""
pass
# Phantom Type: Stateは型チェックのためだけに存在
State = TypeVar("State")
class Features(Generic[State]):
"""特徴量データ: State型パラメータで処理段階を追跡"""
def __init__(self, data: list[float]) -> None:
self.data = data
# 各処理関数は入力・出力の状態を型で明示
def load_features(path: str) -> Features[Raw]:
"""生データの読み込み"""
return Features[Raw]([1.0, 5.0, 3.0, 8.0])
def normalize(features: Features[Raw]) -> Features[Normalized]:
"""正規化: Raw → Normalized"""
mean = sum(features.data) / len(features.data)
std = (sum((x - mean) ** 2 for x in features.data) / len(features.data)) ** 0.5
normalized = [(x - mean) / std for x in features.data]
return Features[Normalized](normalized)
def augment(features: Features[Normalized]) -> Features[Augmented]:
"""データ拡張: Normalized → Augmented(正規化後のみ適用可能)"""
augmented = features.data + [x * 1.1 for x in features.data]
return Features[Augmented](augmented)
def predict(features: Features[Normalized]) -> float:
"""推論: Normalizedデータのみ受け付ける"""
return sum(features.data) * 0.5
# 使用例
raw = load_features("data.csv")
normed = normalize(raw)
result = predict(normed) # OK
# predict(raw) # mypy エラー: Argument 1 has incompatible type "Features[Raw]"
# normalize(normed) # mypy エラー: Argument 1 has incompatible type "Features[Normalized]"
mypy --strict phantom_ml.py を実行すると、コメントアウトした不正な呼び出しがエラーとして報告されます。
トレードオフ: Pythonのphantom typeは静的解析ツール(mypy/pyright)に依存するため、型チェックを実行しない環境ではバグを検出できません。CI/CDパイプラインにmypyチェックを組み込むことで、この制約を補えます。
言語別比較
| 特性 | TypeScript | Rust | Python |
|---|---|---|---|
| 実装方法 | Branded Types (& { __tag: never }) |
PhantomData<T> |
Generic[T] + mypy |
| 型付け方式 | 構造的 | 名前的 | ダック(+型ヒント) |
| ランタイムコスト | ゼロ(コンパイル時消去) | ゼロ(サイズゼロ型) | ゼロ(型ヒントは無視) |
| 型安全性の強度 | 中(as キャストで回避可能) |
高(unsafe以外で回避不可) | 低(実行時は制約なし) |
| ツール | tsc | rustc | mypy / pyright |
| 代表的な使用例 | Angular内部、Branded ID | Tokio、Diesel | typegeist(ML向け) |
TypeStateパターンで状態マシンを型安全に設計する
Phantom Typeの最も実用的な応用がTypeStateパターンです。これは、オブジェクトの状態遷移を型レベルで表現し、不正な状態遷移をコンパイルエラーにするデザインパターンです。
DB接続の状態管理
データベース接続のライフサイクル(切断→接続→トランザクション中)を型で管理する例を見てみましょう。
// typestate_db.rs
use std::marker::PhantomData;
// 状態マーカー型
struct Disconnected;
struct Connected;
struct InTransaction;
// DB接続: 状態を型パラメータで追跡
struct DbConnection<State> {
connection_string: String,
_state: PhantomData<State>,
}
// Disconnected 状態でのみ利用可能なメソッド
impl DbConnection<Disconnected> {
fn new(conn_str: &str) -> Self {
DbConnection {
connection_string: conn_str.to_string(),
_state: PhantomData,
}
}
fn connect(self) -> Result<DbConnection<Connected>, String> {
println!("Connecting to: {}", self.connection_string);
Ok(DbConnection {
connection_string: self.connection_string,
_state: PhantomData,
})
}
}
// Connected 状態でのみ利用可能なメソッド
impl DbConnection<Connected> {
fn query(&self, sql: &str) -> Vec<String> {
println!("Executing: {}", sql);
vec!["result1".to_string()]
}
fn begin_transaction(self) -> DbConnection<InTransaction> {
println!("BEGIN TRANSACTION");
DbConnection {
connection_string: self.connection_string,
_state: PhantomData,
}
}
fn disconnect(self) -> DbConnection<Disconnected> {
println!("Disconnecting");
DbConnection {
connection_string: self.connection_string,
_state: PhantomData,
}
}
}
// InTransaction 状態でのみ利用可能なメソッド
impl DbConnection<InTransaction> {
fn query(&self, sql: &str) -> Vec<String> {
println!("Executing in transaction: {}", sql);
vec!["result1".to_string()]
}
fn commit(self) -> DbConnection<Connected> {
println!("COMMIT");
DbConnection {
connection_string: self.connection_string,
_state: PhantomData,
}
}
fn rollback(self) -> DbConnection<Connected> {
println!("ROLLBACK");
DbConnection {
connection_string: self.connection_string,
_state: PhantomData,
}
}
}
fn main() {
let db = DbConnection::<Disconnected>::new("postgres://localhost/mydb");
// db.query("SELECT 1"); // コンパイルエラー! Disconnected に query() はない
let db = db.connect().unwrap();
let results = db.query("SELECT * FROM users"); // OK
let tx = db.begin_transaction();
tx.query("INSERT INTO logs VALUES ('action')"); // OK
let db = tx.commit(); // トランザクション終了 → Connected に戻る
let _db = db.disconnect();
}
なぜTypeStateパターンが有効か: 従来の enum State { Disconnected, Connected, ... } によるランタイム状態管理では、不正な状態遷移は実行時にパニックやエラーとして発現します。TypeStateパターンでは、不正な遷移がそもそもコンパイルできないため、テストで検証する必要すらありません。
ハマりポイント: Rustの所有権システムとTypeStateの組み合わせには注意が必要です。connect(self) のように self を消費する(ムーブする)設計にすることで、古い状態のオブジェクトが使い続けられることを防ぎます。&self や &mut self では状態遷移を型安全に表現できません。
MLモデルのライフサイクル管理
MLエンジニアにとって身近な例として、モデルの学習・推論ライフサイクルをTypeStateで管理する方法を紹介します。
# model_typestate.py
from typing import Generic, TypeVar
class Untrained:
"""未学習状態"""
pass
class Trained:
"""学習済み状態"""
pass
class Deployed:
"""デプロイ済み状態"""
pass
State = TypeVar("State")
class Model(Generic[State]):
"""MLモデル: 状態を型パラメータで追跡"""
def __init__(self, name: str, weights: list[float] | None = None) -> None:
self.name = name
self.weights = weights
def create_model(name: str) -> Model[Untrained]:
"""モデルの初期化"""
return Model[Untrained](name)
def train(model: Model[Untrained], data: list[float]) -> Model[Trained]:
"""学習: Untrained → Trained"""
weights = [x * 0.1 for x in data] # ダミーの学習処理
return Model[Trained](model.name, weights)
def evaluate(model: Model[Trained]) -> float:
"""評価: Trainedモデルのみ受け付ける"""
if model.weights is None:
raise ValueError("Weights should not be None for trained model")
return sum(model.weights) / len(model.weights)
def deploy(model: Model[Trained]) -> Model[Deployed]:
"""デプロイ: Trained → Deployed"""
return Model[Deployed](model.name, model.weights)
def predict(model: Model[Deployed], input_data: list[float]) -> list[float]:
"""推論: Deployedモデルのみ受け付ける"""
if model.weights is None:
raise ValueError("Weights should not be None for deployed model")
return [sum(x * w for x, w in zip(input_data, model.weights))]
# 正しい使用フロー
model = create_model("my_classifier")
trained = train(model, [1.0, 2.0, 3.0])
score = evaluate(trained)
deployed = deploy(trained)
result = predict(deployed, [4.0, 5.0, 6.0])
# 以下はmypyエラー:
# predict(model, [4.0, 5.0, 6.0]) # Untrainedモデルで推論できない
# predict(trained, [4.0, 5.0, 6.0]) # Trainedだがデプロイされていない
# train(trained, [1.0, 2.0]) # 学習済みモデルを再学習できない
GADTsで型安全なDSLを構築する
GADTsとは何か
GADTs(Generalized Algebraic Data Types、一般化代数的データ型)は、Phantom Typeをさらに発展させた型システムの機能です。通常の代数的データ型(ADT)では、すべてのコンストラクタが同じ型パラメータを返しますが、GADTsではコンストラクタごとに異なる型パラメータを指定できます。
通常のADTでは LitInt 42 も LitBool True も Expr a を返すため、型パラメータ a からは「この式が整数なのかブール値なのか」を区別できません。GADTsでは、LitInt は Expr Int を、LitBool は Expr Bool を返すと宣言でき、パターンマッチ時にコンパイラが型を絞り込みます。
Haskellでの型安全な式インタプリタ
GADTsの典型的な応用例は、型安全なDSL(ドメイン固有言語)のインタプリタです。
-- typed_expr.hs
{-# LANGUAGE GADTs #-}
-- GADTsによる型付き式の定義
data Expr a where
LitInt :: Int -> Expr Int -- 整数リテラル → Expr Int
LitBool :: Bool -> Expr Bool -- ブールリテラル → Expr Bool
Add :: Expr Int -> Expr Int -> Expr Int -- 加算: Int同士のみ
Eq :: Eq a => Expr a -> Expr a -> Expr Bool -- 等値比較
If :: Expr Bool -> Expr a -> Expr a -> Expr a -- 条件分岐
-- 型安全な評価関数: パターンマッチで型が自動的に絞り込まれる
eval :: Expr a -> a
eval (LitInt n) = n -- ここで a = Int と確定
eval (LitBool b) = b -- ここで a = Bool と確定
eval (Add e1 e2) = eval e1 + eval e2 -- e1, e2 は Expr Int と確定
eval (Eq e1 e2) = eval e1 == eval e2
eval (If cond t f) = if eval cond then eval t else eval f
-- 使用例
example :: Int
example = eval (If (Eq (LitInt 1) (LitInt 1))
(Add (LitInt 10) (LitInt 20))
(LitInt 0))
-- 結果: 30
-- 以下はコンパイルエラー:
-- eval (Add (LitInt 1) (LitBool True)) -- Int + Bool は型エラー
-- eval (If (LitInt 1) ...) -- If の条件は Bool でなければならない
GADTsなしの場合との比較: GADTsなしで同様のインタプリタを書くと、eval 関数の返り値型が Either Int Bool などの直和型になり、すべての分岐で Left と Right の場合分けが必要です。さらに、「Add に Bool が渡された場合」のランタイムエラー処理も必要になります。GADTsではこれらが完全に不要になります。
OCamlでのGADTs実装
OCamlはバージョン4.00以降でGADTsをネイティブサポートしています。Haskellと同様の式インタプリタをOCamlで実装してみましょう。
(* typed_expr.ml *)
(* OCaml の GADTs: コンストラクタに型の等式制約を付加できる *)
type _ expr =
| Int : int -> int expr (* int expr を返す *)
| Bool : bool -> bool expr (* bool expr を返す *)
| Add : int expr * int expr -> int expr
| Eq : 'a expr * 'a expr -> bool expr
| If : bool expr * 'a expr * 'a expr -> 'a expr
(* 型安全な評価関数 *)
let rec eval : type a. a expr -> a = function
| Int n -> n
| Bool b -> b
| Add (a, b) -> eval a + eval b
| Eq (a, b) -> eval a = eval b
| If (c, t, f) -> if eval c then eval t else eval f
(* 使用例 *)
let result = eval (If (Eq (Int 1, Int 1), Add (Int 10, Int 20), Int 0))
(* result = 30 *)
(* Add (Int 1, Bool true) はコンパイルエラー *)
注意: GADTsは現在、Haskell、OCaml、Scala 3でネイティブサポートされています。TypeScript、Rust、Pythonではネイティブサポートがなく、トレイトや条件型を駆使した近似的な実装になります。言語選択の際は、GADTsの必要性も考慮してください。
Phantom TypeとGADTsの関係
Phantom TypeとGADTsは密接に関連していますが、以下の違いがあります。
| 特性 | Phantom Type | GADTs |
|---|---|---|
| 概念 | 型パラメータが値に使われない | コンストラクタごとに型パラメータを制約 |
| パターンマッチでの型絞り込み | なし | あり(コンパイラが自動推論) |
| 言語サポート | ジェネリクスがあれば実装可能 | 言語レベルのサポートが必要 |
| 表現力 | 状態のマーキング | 型の証明・不変条件の強制 |
| 主なユースケース | TypeState、ブランド型 | 型安全DSL、インタプリタ |
| 対応言語 | TypeScript, Rust, Python, Java, Swift... | Haskell, OCaml, Scala 3 |
Phantom Typeは「この値は検証済み」「この値はメートル単位」といったラベル付けに使い、GADTsは「この式は必ず整数を返す」「この構文木は型安全」といった構造的な型の証明に使います。
MLパイプラインでPhantom Typeを活用する
typegeist: ベイズ階層モデルの型安全性
2025年10月に公開されたarXiv論文「Phantom types for robust hierarchical models with typegeist」(arXiv:2510.26726)は、Phantom Typeをベイズ階層モデルのインデックス安全性に応用した研究です。
ベイズ階層モデル(PyMC、Stanなどで構築)では、パラメータと観測データの対応関係をインデックス配列で管理します。例えば、ラドン汚染モデルでは「各家庭がどの郡に属するか」をインデックスで指定しますが、このインデックスを取り違えるとモデルはエラーなく動作するものの、結果が無意味になります。
# typegeist の概念的な使用例(論文に基づく独自実装)
from typing import Generic, TypeVar
# データの次元を表すマーカー型
class County:
"""郡レベルのパラメータ"""
pass
class Home:
"""家庭レベルの観測データ"""
pass
Dim = TypeVar("Dim")
Source = TypeVar("Source")
class Vec(Generic[Dim]):
"""特定の次元に紐づくベクトル"""
def __init__(self, data: list[float]) -> None:
self.data = data
class Idx(Generic[Dim, Source]):
"""Dim次元のパラメータを Source次元にマッピングするインデックス"""
def __init__(self, indices: list[int]) -> None:
self.indices = indices
def gather(vec: Vec[Dim], idx: Idx[Dim, Source]) -> Vec[Source]:
"""インデックスを使ってベクトルの要素を収集"""
return Vec[Source]([vec.data[i] for i in idx.indices])
# 正しい使用例
county_effect = Vec[County]([0.1, -0.2, 0.3]) # 郡ごとの効果
county_idx = Idx[County, Home]([0, 1, 1, 2, 0]) # 各家庭の所属郡
# 郡レベルの効果を家庭レベルに展開
home_effects = gather(county_effect, county_idx) # OK: Vec[County] + Idx[County, Home] → Vec[Home]
# 不正な使用例(mypyがエラーを報告)
# home_idx = Idx[Home, Home]([0, 1, 2, 3, 4])
# wrong = gather(county_effect, home_idx)
# mypy error: Idx[Home, Home] は Idx[County, Source] に代入できない
論文の著者らの報告によると、このアプローチにより、ベイズ階層モデルのインデックス取り違えバグをコーディング段階で検出できるようになり、デバッグに費やす時間を大幅に削減できたとされています。
特徴量パイプラインの型安全設計
実務のMLパイプラインでは、複数の前処理ステップを組み合わせます。各ステップの適用順序を型で強制する設計を見てみましょう。
# feature_pipeline.py
from typing import Generic, TypeVar
from dataclasses import dataclass
# パイプラインの段階を表すマーカー型
class Ingested:
"""取り込み済み"""
pass
class Cleaned:
"""クリーニング済み"""
pass
class Encoded:
"""エンコーディング済み"""
pass
class Scaled:
"""スケーリング済み"""
pass
Stage = TypeVar("Stage")
@dataclass
class DataFrame(Generic[Stage]):
"""型安全なデータフレーム"""
columns: list[str]
data: list[list[float]]
def ingest(path: str) -> DataFrame[Ingested]:
"""データ取り込み"""
return DataFrame[Ingested](
columns=["age", "income", "category"],
data=[[25, 50000, 1], [30, 60000, 2]]
)
def clean(df: DataFrame[Ingested]) -> DataFrame[Cleaned]:
"""欠損値処理・外れ値除去: Ingested → Cleaned"""
# 欠損値処理のロジック
return DataFrame[Cleaned](df.columns, df.data)
def encode(df: DataFrame[Cleaned]) -> DataFrame[Encoded]:
"""カテゴリカル変数のエンコーディング: Cleaned → Encoded"""
return DataFrame[Encoded](df.columns, df.data)
def scale(df: DataFrame[Encoded]) -> DataFrame[Scaled]:
"""特徴量スケーリング: Encoded → Scaled"""
return DataFrame[Scaled](df.columns, df.data)
def train_model(df: DataFrame[Scaled]) -> None:
"""モデル学習: Scaledデータのみ受け付ける"""
print(f"Training with {len(df.data)} samples")
# 正しいパイプライン
raw = ingest("data.csv")
cleaned = clean(raw)
encoded = encode(cleaned)
scaled = scale(encoded)
train_model(scaled) # OK
# 不正なパイプライン(mypyエラー)
# train_model(raw) # Ingestedでは学習できない
# train_model(encoded) # Encodedだがスケーリングされていない
# encode(raw) # クリーニングせずにエンコードできない
制約: このパターンはパイプラインが線形(各ステップが1入力1出力)の場合に適しています。分岐や合流のあるDAG型パイプラインでは、より複雑な型設計が必要になり、Phantom Typeだけでは表現が難しくなります。その場合は、ランタイムの状態管理と組み合わせるハイブリッドアプローチが実用的です。
よくある問題と解決方法
Phantom TypeやGADTsの導入時に遭遇しやすい問題をまとめました。
| 問題 | 原因 | 解決方法 |
|---|---|---|
TypeScriptで Branded<number, 'A'> と number が互換 |
unique symbol ではなく文字列リテラルを使用 |
{ readonly [K in Brand]: never } パターンに変更 |
Rustで unused type parameter エラー |
型パラメータが構造体のフィールドに未使用 |
PhantomData<T> フィールドを追加 |
| Pythonのphantom typeが実行時に無視される | Pythonの型ヒントは実行時に強制されない | CI/CDにmypy --strict を組み込む |
| GADTsのパターンマッチで型推論が失敗 | OCamlのGADTsでは明示的な型注釈が必要な場合がある |
let rec eval : type a. a expr -> a = ... 形式で型注釈を追加 |
TypeStateパターンで self を消費する設計が使いづらい |
Rustの所有権ムーブが直感に反する | 状態遷移メソッドが self を消費して新しい状態の値を返す設計を徹底する |
まとめと次のステップ
まとめ:
- Phantom Typeは、型パラメータを値に使わず、コンパイル時の型チェックのためだけに存在させるテクニック。ランタイムコストゼロで、不正な状態や操作をコンパイルエラーとして検出できる
- TypeStateパターンは、Phantom Typeの実用的な応用であり、DB接続やMLモデルのライフサイクルなど、状態遷移の安全性を型で保証する
- GADTsは、Phantom Typeを拡張し、コンストラクタごとに型パラメータを制約できる。型安全なDSLインタプリタの構築に有効だが、対応言語が限られる(Haskell、OCaml、Scala 3)
- MLパイプラインへの応用: typegeist論文で示されたように、テンソル次元の型安全性やパイプラインの段階管理に実用的価値がある
- TypeScript・Rust・Pythonそれぞれで実装方法が異なるが、基本概念は共通。言語の型システム特性に応じた実装パターンを選択する
次にやるべきこと:
- 自身のプロジェクトでID型の混同(ユーザーID/商品IDなど)が起きていないか確認し、Branded Typesの導入を検討する
- MLパイプラインの前処理ステップにPhantom Typeを適用し、処理順序の型安全性を確保する
- Haskell/OCamlに興味がある場合は、GADTsを使った型安全なDSLの構築に挑戦する
参考
- Phantom type parameters - Rust By Example
- How to Use PhantomData in Rust (2026年1月)
- Notes on TypeScript: Phantom Types
- TypeScript で幽霊型っぽいものをつくる
- Phantom types for robust hierarchical models with typegeist (arXiv:2510.26726)
- Playing Fast and Loose With GADTs (2025年3月)
- OCaml - Generalized algebraic datatypes
- Haskell/GADT - Wikibooks
- PythonでPhantom Type(幽霊型)を使って静的にプログラムの欠陥を発見する
- Phantom type - HaskellWiki
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。