有限オートマトン
型システムで有限オートマトンを実装することで、ClaudeCodeは状態遷移の正しさをコンパイル時に保証できます。弊社では、ワークフローやプロトコルの実装でこのパターンを活用し、必要に応じてファントムタイプ(幽霊型)と組み合わせて使用します。
弊社の推奨ルール
- 状態と遷移を型レベルで定義し、不正な遷移を防ぐ
- ファントムタイプ(幽霊型)で状態を追跡する
- イミュータブルな設計で各遷移が新しいインスタンスを返す
- ビジネスワークフローを型安全に実装
- 状態ごとに利用可能なメソッドを制限
ClaudeCodeでの利用
ワークフロー実装
ドキュメントやビジネスプロセスの状態遷移を管理したい場合
「ドキュメントのワークフローを有限オートマトンで実装」
型安全な状態管理
状態遷移の正しさをコンパイル時に保証したい場合
「注文の状態遷移を型安全に管理して」
ファントムタイプ実装
複雑な状態遷移でコンパイル時チェックを強化する場合
「Draft→Review→Published→Archivedの状態遷移をファントムタイプで実装」
不正遷移の防止
実行時エラーではなくコンパイル時にエラーを検出したい場合
「不正な状態遷移をコンパイル時にエラーにして」
クラス分割による実装
ファイルの状態管理のように、Open状態とClosed状態で保持するデータが全く異なる場合は、クラスを分割して実装します。これにより、各状態で必要なデータのみを保持し、不要なデータへのアクセスを防げます。
// ファイルの状態:データ構造が異なる
class ClosedFile {
constructor(private readonly path: string) {}
// Closed状態でのみ開くことができる
async open(): Promise<OpenFile> {
const handle = await fs.open(this.path, 'r');
return new OpenFile(this.path, handle);
}
}
class OpenFile {
constructor(
private readonly path: string,
private readonly handle: FileHandle // Open状態のみが持つデータ
) {}
// Open状態でのみ読み込み可能
async read(): Promise<string> {
return await this.handle.readFile('utf8');
}
// Open状態でのみ閉じることができる
async close(): Promise<ClosedFile> {
await this.handle.close();
return new ClosedFile(this.path);
}
}
この例では、ファイルハンドルはOpen状態でのみ存在し、Closed状態では存在しません。このような場合、クラス分割により各状態に必要なデータのみを保持できます。
ファントムタイプによる実装
ドキュメントのワークフローのように、基本的なデータ構造は共通で、利用可能な操作だけが状態によって異なる場合は、ファントムタイプ(幽霊型)を使います。ClaudeCodeにプロンプトで「ファントムタイプ」や「幽霊型」を明示することで、この実装パターンを確実に生成できます
// ファントムタイプ:共通データ構造で操作を制限
type Draft = { _state: "draft" };
type Published = { _state: "published" };
type Archived = { _state: "archived" };
class Document<State = Draft> {
private readonly _phantom!: State;
constructor(
private readonly content: string,
private readonly metadata: Record<string, unknown> = {}
) {
Object.freeze(this);
}
// Draft状態でのみ編集可能
edit(this: Document<Draft>, newContent: string): Document<Draft> {
return new Document<Draft>(newContent, this.metadata);
}
// Draft → Published の遷移
publish(this: Document<Draft>): Document<Published> {
return new Document<Published>(this.content, {
...this.metadata,
publishedAt: new Date(),
});
}
}
ファントムタイプの利点は、contentやmetadataといった共通データを1つのクラスで管理できることです。ただし、すべてのメソッドが1つのクラスに集約されるため、状態が多い場合はクラスが肥大化する可能性があります。
Union型を使った柔軟な型定義
複数の状態を扱う場合、Union型を使って型安全性を保ちながら柔軟な実装が可能です。
// 編集可能な状態のUnion型
type EditableDocument = Document<Draft>;
type ReadOnlyDocument = Document<Published> | Document<Archived>;
// すべての状態のUnion型
type AnyDocument = Document<Draft> | Document<Published> | Document<Archived>;
// Union型を受け取る関数
function processDocument(doc: AnyDocument): void {
// 共通のプロパティにはアクセス可能
console.log(doc.content);
// 型ガードで状態を判定
if (isDraft(doc)) {
// Draft状態の操作が可能
const edited = doc.edit("新しい内容");
const published = edited.publish();
}
}
// 型ガード関数
function isDraft(doc: AnyDocument): doc is Document<Draft> {
// 実際の判定は、metadataなどから状態を識別
return !('publishedAt' in doc.metadata) && !('archivedAt' in doc.metadata);
}
// 複数の状態を許可するメソッド
class DocumentManager {
// Draft または Published を受け取る
preview(doc: Document<Draft> | Document<Published>): string {
return `プレビュー: ${doc.content.substring(0, 100)}...`;
}
// すべての状態を受け取る
getWordCount(doc: AnyDocument): number {
return doc.content.split(' ').length;
}
}
Union型を使うことで、特定の処理で複数の状態を受け入れることができ、型安全性を保ちながら柔軟な実装が可能になります。
状態遷移を型で管理する
幽霊型を使った有限オートマトンで、ドキュメントのワークフローを型安全に管理できます。
// 状態を表す型定義
type Draft = { _state: "draft" };
type Published = { _state: "published" };
type Archived = { _state: "archived" };
class Doc<State = Draft> {
// 幽霊プロパティで状態を追跡
private readonly _phantom!: State;
constructor(
private readonly content: string,
private readonly metadata: Record<string, unknown> = {}
) {
Object.freeze(this);
}
// Draft → Publishedの遷移のみ許可
publish(this: Doc<Draft>): Doc<Published> {
return new Doc<Published>(this.content, {
...this.metadata,
publishedAt: new Date()
});
}
// Published → Archivedの遷移のみ許可
archive(this: Doc<Published>): Doc<Archived> {
return new Doc<Archived>(this.content, {
...this.metadata,
archivedAt: new Date()
});
}
// Draft状態でのみ編集可能
edit(this: Doc<Draft>, newContent: string): Doc<Draft> {
return new Doc<Draft>(newContent, this.metadata);
}
// Published状態でのみ公開URLを生成
getPublicUrl(this: Doc<Published>): string {
const id = Math.random().toString(36).substring(2, 9);
return `/docs/published/${id}`;
}
}
この実装では、thisパラメータを使ってメソッドが特定の状態でのみ呼び出し可能であることを強制しています。
// 使用例
const draft = new Doc<Draft>("初稿");
// ✅ OK: Draftは編集可能
const edited = draft.edit("修正版");
// ✅ OK: Draftは公開可能
const published = edited.publish();
// ✅ OK: Publishedはアーカイブ可能
const archived = published.archive();
// ❌ エラー: Publishedは編集不可
// published.edit("変更"); // コンパイルエラー
// ❌ エラー: Draftは直接アーカイブ不可
// draft.archive(); // コンパイルエラー
// ❌ エラー: DraftにはpublicURLがない
// draft.getPublicUrl(); // コンパイルエラー
不正な状態遷移や操作はコンパイル時にエラーとして検出され、実行時エラーを防ぐことができます。
クラス分割とファントムタイプの使い分け
有限オートマトンを実装する際、状態ごとにクラスを分割する方法とファントムタイプを使う方法があります。それぞれに適した使用場面があり、弊社では以下の基準で使い分けます。
クラス分割を選ぶ場合
- 各状態でデータ構造が大きく異なる(ファイルのOpen/Closed)
- 状態固有のメソッドが多い(各状態で5個以上の固有メソッド)
- 状態遷移が単純で分岐が少ない
- チーム内でオブジェクト指向に慣れている
ファントムタイプを選ぶ場合
- 基本的なデータ構造が共通(ドキュメントのワークフロー)
- 利用可能な操作だけが状態によって異なる
- メソッドの再利用が多い
- 状態遷移が複雑で分岐が多い
メソッドの重複問題への対処
ファントムタイプを使用する場合、複数の状態で同じメソッドが必要な場合にメソッドの重複が問題になることがあります。例えば、DraftとPublished両方でプレビューが必要な場合です。
class Doc<State> {
// DraftとPublishedの両方でプレビュー可能にしたい
preview(this: Doc<Draft | Published>): string {
return `Preview: ${this.content.substring(0, 100)}...`;
}
// 複数の状態で利用可能なメソッドはUnion型で定義
getWordCount(this: Doc<Draft | Published | Archived>): number {
return this.content.split(' ').length;
}
}
この方法では、複数の状態で共通のメソッドを定義できますが、どの状態で何が可能かが分かりにくくなる場合があります。そのような場合は、状態ごとにクラスを分割し、共通メソッドは基底クラスや共通インターフェースで定義する方が適切です。
型安全な状態遷移の仕組み
ファントムタイプによる有限オートマトンは、TypeScriptの構造的型付けとthisパラメータの型注釈を組み合わせて実現されます。
const draft = new Doc<Draft>("初稿");
// ✅ OK: Draftは編集可能
const edited = draft.edit("修正版");
// ✅ OK: Draftは公開可能
const published = edited.publish();
// ✅ OK: Publishedはアーカイブ可能
const archived = published.archive();
// ❌ エラー: Publishedは編集不可
// published.edit("変更");
// エラー: The 'this' context of type 'Doc<Published>' is not assignable to
// method's 'this' of type 'Doc<Draft>'
// ❌ エラー: Draftは直接アーカイブ不可
// draft.archive();
// エラー: The 'this' context of type 'Doc<Draft>' is not assignable to
// method's 'this' of type 'Doc<Published>'
コンパイラは各メソッドのthisパラメータの型をチェックし、現在の状態で呼び出し可能なメソッドのみを許可します。これにより、不正な状態遷移は実行時ではなくコンパイル時にエラーとして検出されます。
複雑な状態遷移での使い分け
実際のビジネスワークフローでは、より複雑な状態遷移が必要になります。弊社では、状態の複雑さとメソッドの共通性に応じて実装方法を選択します。
type Review = { _state: "review" };
type Rejected = { _state: "rejected" };
class Article<State = Draft> {
private readonly _phantom!: State;
constructor(
private readonly content: string,
private readonly history: string[] = []
) {
Object.freeze(this);
}
// Draft → Review
submitForReview(this: Article<Draft>): Article<Review> {
return new Article<Review>(
this.content,
[...this.history, "Submitted for review"]
);
}
// Review → Published (承認)
approve(this: Article<Review>): Article<Published> {
return new Article<Published>(
this.content,
[...this.history, "Approved and published"]
);
}
// Review → Rejected (却下)
reject(this: Article<Review>, reason: string): Article<Rejected> {
return new Article<Rejected>(
this.content,
[...this.history, `Rejected: ${reason}`]
);
}
// Rejected → Draft (修正して再提出)
revise(this: Article<Rejected>, newContent: string): Article<Draft> {
return new Article<Draft>(
newContent,
[...this.history, "Revised after rejection"]
);
}
}
承認ワークフローのように分岐が多い場合でも、基本データが共通ならファントムタイプが適しています。各状態で許可される操作が明確になり、ビジネスルールがコードに直接反映されます。
ハイブリッドアプローチ
大規模なシステムでは、クラス分割とファントムタイプを組み合わせることも有効です。例えば、主要な状態はクラスで分割し、サブ状態はファントムタイプで管理します。
// 主要な状態はクラス分割
class ActiveOrder {
// アクティブな注文の共通処理
protected constructor(
protected readonly orderId: string,
protected readonly items: string[]
) {}
}
// サブ状態はファントムタイプで管理
class ProcessingOrder<SubState> extends ActiveOrder {
private readonly _phantom!: SubState;
ship(this: ProcessingOrder<Paid>): ShippedOrder {
return new ShippedOrder(this.orderId, this.items);
}
}
class ShippedOrder extends ActiveOrder {
// 配送済み固有の処理
}
このアプローチにより、大まかな状態遷移はクラスで表現し、詳細な制御はファントムタイプで行うことができます。
ECサイトの注文処理の例
注文処理のような複雑な状態遷移では、要件に応じて適切な方法を選択します。
type Pending = { _state: "pending" };
type Paid = { _state: "paid" };
type Shipped = { _state: "shipped" };
type Delivered = { _state: "delivered" };
type Cancelled = { _state: "cancelled" };
class Order<State = Pending> {
private readonly _phantom!: State;
constructor(
private readonly orderId: string,
private readonly items: readonly string[],
private readonly metadata: Record<string, unknown> = {}
) {
Object.freeze(this);
}
// Pending → Paid
pay(this: Order<Pending>, paymentId: string): Order<Paid> {
return new Order<Paid>(this.orderId, this.items, {
...this.metadata,
paymentId,
paidAt: new Date(),
});
}
// Pending → Cancelled
cancel(this: Order<Pending>, reason: string): Order<Cancelled> {
return new Order<Cancelled>(this.orderId, this.items, {
...this.metadata,
cancelReason: reason,
cancelledAt: new Date(),
});
}
// Paid → Shipped
ship(this: Order<Paid>, trackingNumber: string): Order<Shipped> {
return new Order<Shipped>(this.orderId, this.items, {
...this.metadata,
trackingNumber,
shippedAt: new Date(),
});
}
// Shipped → Delivered
deliver(this: Order<Shipped>): Order<Delivered> {
return new Order<Delivered>(this.orderId, this.items, {
...this.metadata,
deliveredAt: new Date(),
});
}
// Paid → Cancelled (払い戻し)
refund(this: Order<Paid>, reason: string): Order<Cancelled> {
return new Order<Cancelled>(this.orderId, this.items, {
...this.metadata,
cancelReason: reason,
refundedAt: new Date(),
});
}
}
注文処理では基本的なデータ(注文ID、商品リスト)は共通ですが、状態によって可能な操作が大きく異なるため、ファントムタイプが適しています。支払い前のキャンセルと払い戻しを伴うキャンセルを型レベルで区別でき、ビジネスロジックのミスを防げます。
よくある問題
状態の数が増えすぎる
状態が10個を超える場合は、クラス分割で主要な状態を表現し、ファントムタイプでサブ状態を管理するハイブリッドアプローチを検討します。また、関連する状態をグループ化して、管理しやすくします。
メソッドの肥大化
ファントムタイプで1つのクラスにメソッドが集中しすぎる場合は、クラス分割への移行を検討します。目安として、30個以上のメソッドがある場合は分割を検討します。
条件付き遷移の実装
ビジネスルールによる条件付き遷移(在庫チェックなど)は、メソッド内でランタイムチェックを追加します。型システムは基本的な遷移ルールを保証し、実行時のビジネスロジックと組み合わせて使用します。
シリアライズとデシリアライズ
ファントムタイプは実行時には存在しないため、JSONへのシリアライズは簡単です。デシリアライズ時は、状態を表すフィールドを確認して適切な型でインスタンス化します。
並行状態の管理
支払い状態と配送状態のような独立した状態は、複数の型パラメータまたは複合型で管理します。これにより、それぞれの状態を独立して遷移させることができます。