状態を追加した。ハンドラを忘れた。誰も警告しなかった。

ステートマシンはきれいに始まる。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の型だ。ステートマシンでは、これはほぼ常にstatuskind、または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分岐の中のstateneverに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や別々のマシンに分割することを検討する。

すべてを書き換えずにこれを導入する方法

最もバグを起こすステートマシンから始めよう。

  1. stringや広いenumとして型付けされたstatusフィールドを見つける。closed unionに置き換える。
  2. それをswitchする関数にassertNeverを追加する。
  3. コンパイラに導かせる。
  4. 新しいreducerでassertNeverを必須とするコードレビューチェックリストを追加する。

XStateを使っていても、状態値をswitchするマッパーやセレクタを書くことになる。これらの関数も依然としてexhaustiveness checkingを必要とする。

FAQ

switchの代わりにif/elseでも機能するか?

機能するが、脆弱だ。最後のelseassertNeverを呼び出す必要がある。条件を並び替えたり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つの分岐も見落とさない。