状態を追加した。ハンドラを忘れた。誰も警告しなかった。
ステートマシンはきれいに始まる。3つの値、3つのswitch分岐。そしてリトライロジックが4つ目の状態を追加する。部分失敗が5つ目を追加する。reducerは更新したが、ステータスバッジコンポーネント、分析マッパー、エクスポートフォーマッタを見落とした。
すべてコンパイルを通る。バグは2スプリント後に浮上する。手動でテストしたことのないコーナーケースでユーザーが空白の画面に遭遇したときだ。
TypeScriptはこれを不可能にできる。linterルールではなく。テストでもなく。型システムそのもので。
exhaustiveness checkingが実際に意味すること
exhaustiveness checkingとは、コンパイラが型のすべてのvariantを処理したことを証明することだ。RustやOCamlはこれをデフォルトで行う。TypeScriptにもあるが、明示的に求める必要がある。
このトリックは2つのものを組み合わせる: discriminated unionと、never型だけを受け入れるヘルパーだ。
discriminated unionが状態をモデル化する方法
discriminated unionとは、すべてのvariantが共有されたリテラルプロパティ(discriminantと呼ばれる)を持つTypeScriptの型だ。ステートマシンでは、これはほぼ常にstatus、kind、またはtypeフィールドだ。
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
各variantは、その状態に関連するデータだけを正確に持つ。idleにはオプションのdataフィールドはなく、successにはmessageフィールドはない。オブジェクトの形状がどの状態にいるかを教えてくれ、コンパイラはswitchの中で型を自動的にnarrowする。
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
}
}
case "loading"の中では、型は{ status: "loading"; requestId: string }なので、state.requestIdが利用可能だ。コンパイラは自動的にnarrowする。なぜなら、各variantのstatusリテラルが異なるからだ。
これはstring enumやboolean flagのスープより優れている。しかしまだexhaustiveではない。errorのケースを削除してもTypeScriptはコンパイルを通す。関数は暗黙的にundefinedを返し、コンパイラは黙っている。
exhaustiveな処理を強制するneverのトリック
これがヘルパーだ:
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}
この関数はnever、つまり空のunion、値を持たない型だけを受け入れる。コンパイラに嘘をつかない限り、実際の値でassertNeverを呼び出すことは不可能だ。
さあ、switchの末尾に追加しよう:
function handleState(state: State): string {
switch (state.status) {
case "idle":
return "Waiting..."
case "loading":
return `Loading ${state.requestId}`
case "success":
return state.data
case "error":
return state.message
default:
return assertNever(state)
}
}
defaultの上でStateのすべての可能なvariantが処理されていれば、default分岐の中のstateはneverにnarrowされている。assertNever(state)の呼び出しはコンパイルを通る。
新しい状態を追加してケースを忘れた場合、default分岐は実際の型を受け取ることになる。実際の型をneverパラメータに渡すことはできない。コンパイラは型エラーを投げる。
実際に失敗する様子を見る
retrying状態を追加する:
type State =
| { status: "idle" }
| { status: "loading"; requestId: string }
| { status: "success"; data: string }
| { status: "error"; message: string }
| { status: "retrying"; attempt: number; lastError: string }
これでhandleStateはコンパイルに失敗する:
Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.
エラーはdefault分岐を直接指す。runtimeのクラッシュではない。この特定の関数でretryingが何を意味するか決めるまで、コンパイル時にビルドを拒否される。
Stateをswitchする各関数は独自のエラーを得る。コンパイラは作業の先延ばしを許さない。
このパターンはあらゆるunion型にスケールする
これはステートマシンに特有のものではない。あらゆるdiscriminated unionが同じ扱いの恩恵を受ける。
イベントハンドラ:
type Event =
| { type: "USER_LOGIN"; userId: string }
| { type: "USER_LOGOUT" }
| { type: "PAGE_VIEW"; path: string }
function handleEvent(event: Event): void {
switch (event.type) {
case "USER_LOGIN":
trackLogin(event.userId)
return
case "USER_LOGOUT":
trackLogout()
return
case "PAGE_VIEW":
trackPageView(event.path)
return
default:
assertNever(event)
}
}
Reduxスタイルのreducer:
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" }
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount
case "decrement":
return state - action.amount
case "reset":
return 0
default:
return assertNever(action)
}
}
リテラルdiscriminantを持つオブジェクトのunion。それをswitchする。デフォルトでassertNeverにする。variantを追加すれば、処理するまですべてのswitchが爆発する。
ここで壊れるところと、それにどう対処するか
exhaustiveness checkingは魔法ではない。どこにでも採用する前に、鋭いエッジを知っておけ。
discriminated unionを使わなければならない
これは単純なstring enumやboolean flagでは機能しない。コンパイラはnarrowするための共有されたリテラルプロパティを必要とする。
// This does NOT work for exhaustiveness checking
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
}
assertNeverはclosed unionでのみexhaustivenessを証明する。enumでは、コンパイラは一致するstringが有効であると仮定する。ケースを見落としたことを証明できない。
anyや型アサーションを使ってはいけない
as Stateでキャストしたり、any型のレスポンスから取り出したりすると、コンパイラはnarrowをスキップする。境界で検証し、中の型を信頼する。
assertNeverは最後の手段としてruntimeでthrowする
この関数はまずcompile-timeのガードであり、次にruntimeのガードだ。理論上、throwに到達することはない。実際には、キャストや古いJSONがそれを安全網にする。暗黙的にundefinedを返すよりも良い。
分岐が多いと騒がしくなる
11番目のケースを追加すると、その型をswitchするすべての関数でエラーが発生する。それが狙いだ。しかし、unionが6〜7のvariantを超えて成長したら、より小さなsub-unionや別々のマシンに分割することを検討する。
すべてを書き換えずにこれを導入する方法
最もバグを起こすステートマシンから始めよう。
stringや広いenumとして型付けされたstatusフィールドを見つける。closed unionに置き換える。- それをswitchする関数に
assertNeverを追加する。 - コンパイラに導かせる。
- 新しいreducerで
assertNeverを必須とするコードレビューチェックリストを追加する。
XStateを使っていても、状態値をswitchするマッパーやセレクタを書くことになる。これらの関数も依然としてexhaustiveness checkingを必要とする。
FAQ
switchの代わりにif/elseでも機能するか?
機能するが、脆弱だ。最後のelseでassertNeverを呼び出す必要がある。条件を並び替えたりearly returnを追加したりすると、うっかりチェックを落としてしまう可能性がある。switchの方が安全だ。なぜならdefault分岐は明確だからだ。
特定の関数で一部の状態を本当に気にしない場合はどうするか?
明示的に処理する。偶然assertNeverに落ちるのを許さない。
case "retrying":
// No-op in this context
return ""
コンパイラは決定を可視化することを強制する。それが狙いだ。
assertNeverから値を返すことはできるか?
neverを返す関数は、実際には決して返らないため、あらゆるreturn型に代入可能だ。throwの代わりにフォールバックが欲しい場合:
function assertNever(value: never, fallback: string): string {
console.error("Unhandled state:", value)
return fallback
}
value: neverパラメータがcompile-timeのチェックを維持する。
これはJavaScriptでも機能するか?
いいえ。これはcompile-timeのTypeScript機能だ。runtimeでは、assertNeverは単にthrowする関数に過ぎない。安全性は、それに到達しうるコードのビルドを拒否する型チェッカーから来る。
コンパイラをあなたの2人目のレビュアーにする
状態を追加するたびに、reducer、UI、分析パイプライン、エクスポートロジックに作業を生み出す。デフォルトの結果は、それらのうちの1つを見落として、shipして、後で気づくことだ。
exhaustiveness checkingはデフォルトを覆す。コンパイラはswitch文のチェックを決して忘れないレビュアーになる。テストを置き換えるわけではないが、テストがほとんどカバーしないバグのカテゴリーを捕捉する: 単にコードを書かなければならなかったことを知らなかったというものだ。
ヘルパーを追加する。1つのreducerで使う。次にステートマシンを拡張するときは、TypeScriptにどこに作業があるか教えさせる。1つの分岐も見落とさない。