TypeScript

判別可能なユニオン型で状態を安全に表現する

読了 7分
TypeScript

判別可能なユニオン型で状態を安全に表現する

非同期処理のUIで「読み込み中・成功・失敗」を別々のフラグで持っていたら、ありえない組み合わせのバグに悩まされたので、そのときに学んだことのメモです。

結論を先に書くと、TypeScriptの 判別可能なユニオン型(discriminated union) を使うと、不正な状態をそもそも作れなくできます。

まずフラグの組み合わせは「ありえない状態」を生む

ありがちなのが、状態をいくつかのプロパティでバラバラに持つ書き方です。自分もこれでした。

type State = {
  isLoading: boolean
  data: User | null
  error: Error | null
}

一見問題なさそうですが、この型は次のような 矛盾した状態 も許してしまいます。

  • isLoading: true なのに dataerror も埋まっている
  • isLoading: falsedataerrornull(成功でも失敗でもない宙ぶらりん)

boolean が3つあれば組み合わせは8通り。そのうち意味を持つのはごく一部で、残りは「コンパイルは通るけど起きてはいけない状態」です。コンポーネント側では if (error) if (data) を何度も書くことになって、漏れも生まれます。まさにこの漏れでハマりました。

状態ごとにまとまった型を並べる

判別可能なユニオン型では、状態を「とりうる形ごと」に分けて定義します。各メンバーに共通の 判別用フィールド(ここでは status)を持たせるのがポイントです。

type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error }

こうすると、data が存在するのは status: 'success' のときだけ、error が存在するのは status: 'error' のときだけ、と型レベルで保証されます。「ローディング中なのにdataがある」みたいな状態は、もう型として書けません。これは気持ちいいです。

switchで網羅的に分岐する

判別用フィールドで switch すると、その分岐の中ではメンバーの型が 絞り込まれます(narrowing)。success の枝では state.data に安全にアクセスできて、error の枝では state.error が使えます。

function View({ state }: { state: RequestState }) {
  switch (state.status) {
    case 'idle':
      return <p>開始を待っています</p>
    case 'loading':
      return <Spinner />
    case 'success':
      return <Profile user={state.data} /> // data はここで確実に存在
    case 'error':
      return <ErrorBanner message={state.error.message} />
  }
}

state.status === 'loading' の枝で state.data を触ろうとすると、その型には data が無いのでコンパイルエラーになります。アクセスする前提が型で守られるわけです。

never で「分岐の取りこぼし」を防ぐ

判別可能なユニオン型のもう一つの強みは、網羅チェック ができることです。defaultnever 型に代入してみます。

function assertNever(value: never): never {
  throw new Error(`未対応の状態: ${JSON.stringify(value)}`)
}

function label(state: RequestState): string {
  switch (state.status) {
    case 'idle':
      return '待機中'
    case 'loading':
      return '読み込み中'
    case 'success':
      return '完了'
    case 'error':
      return '失敗'
    default:
      return assertNever(state) // 取りこぼしがあると型エラー
  }
}

もし将来 { status: 'cancelled' } をユニオンに追加したら、defaultstatenever でなくなって、assertNever の呼び出しがコンパイルエラーになります。新しい状態を足したときに、対応漏れの箇所をコンパイラが教えてくれる のです。これは便利。

使う目安

持ち方ありえない状態分岐の安全性
複数のboolean/null表現できてしまう自前のif頼り
判別可能なユニオン型型で排除できるnarrowingと網羅チェック

「複数のフラグが連動して動いているな」と感じたら、それは一つの状態を分割して持ってしまっているサインだと思います。状態の取りうる形を列挙して、共通の判別フィールドを付けてユニオンにまとめる。これだけで多くのバグを設計段階で締め出せます。

まとめ

  • 状態をフラグで分散させると、矛盾した組み合わせを型が許してしまう
  • 判別可能なユニオン型なら「ありえない状態は表現できない」を実現できる
  • switch で絞り込んで、never で網羅チェックすると、状態追加時の漏れも防げる

絞り込みの仕組みはTypeScript公式のNarrowingに詳しくまとまっています。「不正な状態を表現できないようにする」という発想は、フォームや権限管理など他の場面にも応用できそうです。以上です。