すべてのAPIクライアントの内部には、state machineが隠れている。まずhandshake。次に認証。三番目にデータ送信。最後に切断。この順序を崩すと、実行時エラー、混乱したサーバー、あるいはより悪いことに、静かなデータ破損が起きる。
ほとんどのチームは、これらのルールを実行時のチェックで実現している。if (!connected) throw new Error(...)。誰かがそのチェックを忘れるか、refactorがチェックを飛ばす新しいコードパスを生じさせるまで、それは機能する。気づいたときには、バグはすでに本番環境に出ている。
TypeScriptの型システムは、この種のバグを丸ごと排除できる。巧妙なlint ruleではなく、違法な状態を文字通り表現不可能にすることで。
APIクライアントには隠れたstate machineがある
Session typesは、プロトコル内の有効な操作順序を型システムで実現する技法だ。実行時の文字列フィールドで状態を追跡するのではなく、型パラメータで追跡する。Channel<'idle'> は connect() しか呼べない。Channel<'connected'> は send() と close() しか呼べない。コンパイラーはそれ以外をすべて拒否する。
これは学術的な話ではない。データベーストランザクション、WebSocket接続、OAuthフローを使ったことがあれば、手動でsession protocolを実現していることになる。Session typesは、そのenforcementを実行時からコンパイル時に移すだけだ。
Phantom typesで違法な遷移を表現不可能にする
TypeScriptでの典型的なstate machineを見てみよう:
class Channel {
private state: 'idle' | 'connected' | 'closed' = 'idle';
connect() {
if (this.state !== 'idle') throw new Error('Already connected');
this.state = 'connected';
}
send(msg: string) {
if (this.state !== 'connected') throw new Error('Not connected');
// ...
}
close() {
this.state = 'closed';
}
}
これは問題ないように見えるが、そうではない。send() メソッドは、間違ったタイミングで呼び出すと実行時に例外を投げる。テストで捉えられるかもしれないし、そうでないかもしれない。refactorによって close() の後に新しい send() の呼び出しが生じても、誰も気づかないかもしれない。コンパイラーは何も言わない。
TypeScriptは構造的型付けを採用している。同じ形を持つ2つのclassは、型パラメータが異なっていても互換性がある。Channel<'idle'> と Channel<'connected'> を非互換にするには、型パラメータが構造自体に現れなければならない。
標準的なパターンはprivate phantom fieldだ:
class Channel<State extends string> {
private __state!: State;
private constructor() {}
static create(): Channel<'idle'> {
return new Channel();
}
connect(this: Channel<'idle'>): Channel<'connected'> {
return new Channel();
}
send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
console.log(msg);
return new Channel();
}
close(this: Channel<'connected'>): Channel<'closed'> {
return new Channel();
}
}
__state フィールドは決して値を持たない。それは型パラメータをclassの構造に束縛するためにだけ存在する。privateなので、外部のコードは Channel<'connected'> を偽造できない。型が State なので、Channel<'idle'> と Channel<'connected'> は構造的に異なる型になる。
各メソッドの this パラメータは、そのメソッドを呼び出せる状態を制限する。TypeScriptは this の互換性を呼び出し側でチェックする。メソッド本体の内部だけでなく。
これで違法な呼び出しはコンパイルエラーになる:
const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'
ここで多くの人が躓く。this パラメータは実行時のアノテーションのように見えるが、そうではない。TypeScriptはそれを純粋に呼び出し側の型チェックに使う。受け手の型が一致しなければ、コンパイラーは呼び出しを完全に拒否する。
型安全なファイルアップロードプロトコルの構築
おもちゃの Channel はパターンを示している。ここで、もう少し実際のプロトコルに近いものを見てみよう:認証とチェックサム検証を持つファイルアップロードクライアントだ。
type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';
class Uploader<State extends UploadState> {
private __state!: State;
private file: string;
private constructor(file: string) {
this.file = file;
}
static forFile(file: string): Uploader<'idle'> {
return new Uploader(file);
}
authenticate(
this: Uploader<'idle'>,
token: string
): Uploader<'ready'> {
// verify token...
return new Uploader(this.file);
}
uploadChunk(
this: Uploader<'ready' | 'uploading'>,
data: Uint8Array
): Uploader<'uploading'> {
// stream bytes...
return new Uploader(this.file);
}
finalize(
this: Uploader<'uploading'>,
checksum: string
): Uploader<'verifying'> {
// start verification...
return new Uploader(this.file);
}
verify(
this: Uploader<'verifying'>,
serverChecksum: string
): Uploader<'done'> {
// compare checksums...
return new Uploader(this.file);
}
}
uploadChunk は this 型として Uploader<'ready' | 'uploading'> を受け入れることに注目してほしい。Union typesを使えば、複数の状態から有効なメソッドを表現できる。このメソッドは Uploader<'uploading'> を返すので、最初の呼び出しの後は、finalizeを呼ぶまでアップロード中の状態に固定される。
const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);
const upload = Uploader.forFile('report.pdf')
.authenticate('token-123')
.uploadChunk(chunk1)
.uploadChunk(chunk2)
.finalize('abc123')
.verify('abc123');
// upload is now Uploader<'done'>
uploadChunk() の後に authenticate() を呼んだり、verify() の後に uploadChunk() を呼んだりしようとすると、TypeScriptは即座にエラーを出す。
パターンが窮屈になる場面
このパターンは無償ではない。最大の痛みは、すべての状態遷移が新しいオブジェクトを構築することだ。例では、underlyingな状態が変わっていない場合でも、新しい Uploader を作成している。uploadChunk が Uploader<'uploading'> から Uploader<'uploading'> を返すのがその例だ。状態が多い複雑なオブジェクトでは、これは非効率だ。
状態を再構築するのではなく通過させることで最適化できるが、型が煩雑になる。第二の問題:TypeScriptの this パラメータの不一致に対するエラーメッセージは不可解だ。「The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’」は正確だが、コードを初めて読む人にとっては即座にはわからない。良い命名とコメントは役立つが、開発者体験は完璧ではない。
第三に、このパターンはasyncコードとうまく合成しない。チェーンの途中に await があるとfluent interfaceが崩れ、中間の型付き結果を変数に格納する必要がある。それが決定的な問題ではないが、このパターンは同期的なプロトコル、あるいは注意深く構造化されたasyncプロトコルに最も適していることを意味する。
Tagged unionsは実用的な中道
完全なphantom typeパターンが重いと感じるなら、tagged unionsと網羅的な switch 文は実用的な代替案だ。値レベルでは型レベルではないが、コンパイル時の安全性は依然として得られる:
type UploadState =
| { tag: 'idle'; file: string }
| { tag: 'ready'; file: string; token: string }
| { tag: 'uploading'; file: string; sent: number }
| { tag: 'done'; file: string };
function authenticate(
state: Extract<UploadState, { tag: 'idle' }>,
token: string
): UploadState {
return { tag: 'ready', file: state.file, token };
}
これはより慣用的なTypeScriptであり、ほとんどのチームにとって保守しやすい。トレードオフは、同じ this パラメータの工夫を使わなければ、誤った状態variantを関数に渡すことを型レベルで防げないということだ。コンパイラーは関数内部で捉えるが、呼び出し側は制限されない。
違反が実害をもたらすときにphantom typesを使う
Phantom typesは、プロトコルが十分に複雑で違反が実害をもたらし、かつAPI表面が小さくてすべての遷移を制御できるときに使う。データベースドライバー、ネットワークプロトコルの実装、状態を持つSDKは良い候補だ。
単純なCRUD APIや、追加の型の複雑性が偶発的な実行時チェックより高いものには使わない。
次に if (state !== 'connected') throw new Error(...) と書くとき、そのチェックがコードに属するのか、型に属するのか自問してほしい。TypeScriptの this パラメータとphantom fieldは、プロトコルのenforcementを上流に押し上げる方法を与えてくれる。捉えられるバグは、派手なものではない。コードレビューをすり抜け、テストでカバーしなかったエッジケースで顧客にぶつかるときだけ現れる、静かなミスだ。
それこそが、排除に値するバグだ。