TypeScriptのexhaustiveness checkをsatisfiesで簡単に書く

に公開

みなさん、TypeScript書いてますか?

ふと気づくと、もうTypeScript 1.0から数えても11年が経過しているんですね。筆者も2015年くらいから本格的に触り始めた組なので、そろそろTypeScript 10年選手を名乗ってもよさそうです。

さて、これだけ長いことTypeScriptを触っていると、昔の文法を前提とした手癖というものも蓄積されていくものです。同僚と雑談していたところ、手癖を改めるべき便利なイディオムを教えてもらったので、備忘録として残しておきます。

Before: 手癖で書いていたexhaustiveness check

TypeScriptには、exhaustiveness checkというテクニックがあります。これは、TypeScriptの型システムを利用して、switch文やif文でユニオン型やEnum型の全てのケースを網羅しているかをチェックする手法です。これにより、将来的に新しいケースが追加された場合にコンパイルエラーが発生し、見逃しを防ぐことができます。

簡単なケースとして、次のようなコードを考えてみましょう。

従来のexhaustiveness check(型チェックのみ)
type Direction = 'up' | 'down';

const move = (direction: Direction) => {
  switch (direction) {
    case 'up':
      console.log('Moving up');
      break;
    case 'down':
      console.log('Moving down');
      break;
    default:
      // ここに到達した時点でdirectionは'up'でも'down'でもないはず
      const exhaustiveCheck: never = direction; // (1)
  }
};

このコードを型チェックの観点で考えた場合、default節に到達した時点で、direction'up'でも'down'でもないことが保証されており、never型として扱うことができます。そのため、(1)で明示的に never 型の代入操作を行なっています。面白いことに、これが網羅性チェックとして機能するのです。

例えば、Direction 型に新しい値 'left' を追加して、switch文の変更をし忘れた場合、default節でのdirectionの型は 'left' 型となります。つまり、default節の中では direction の型と値は次のように定義された変数と近い状態になります。

default節のdirectionの型と値
let direction: 'left' = 'left';

もちろん、この変数を exchaustiveCheck: never で定義された変数に代入することはできず、型チェックは失敗します。

型チェック上の振る舞いとしては上記のような説明となるのですが、実用上は「case節でユニオン型やEnum型の要素を網羅できていない場合に、default節で型エラーが起きるのでcase節の更新忘れに気がつける」という非常に強力なチェック機構が実現できます。

これだけでも十分に便利なのですが、「型チェックは通っているが、勝手にサーバー側でデータの種類を増やされてしまったことで、実行時にエラーが起きた」という残念なケースにも対処したいところです。実行タイミングではTypeScriptのチェック機構は無力なので、筆者は愚直に throw new Error(...) を仕込むのが手癖になっています。つまり、次のような形です。

従来のexhaustiveness check(実行時チェック付き)
type Direction = 'up' | 'down';

const move = (direction: Direction) => {
  switch (direction) {
    case 'up':
      console.log('Moving up');
      break;
    case 'down':
      console.log('Moving down');
      break;
    default:
      const exhaustiveCheck: never = direction;
      throw new Error(`Unexpected direction: ${exhaustiveCheck}`); // (2)
  }
};

実行時に予期せぬ値がswitchに投入された場合、(2)でエラーを投げることで、アプリを止めてでも想定しているデータの齟齬に気づけるようにしています。

このように、never型の代入による網羅性の保証と、実行時のエラーを組み合わせることで、TypeScriptの型システムを最大限に活用しつつ、実行時のエラー検出も実現していました。

After: satisfiesを使ったexhaustiveness check

これまでは前述の記法で十分だと考えていたのですが、satisfies演算子を使うことでより簡潔に書けることを、次の記事で知りました。

https://azukiazusa.dev/blog/exhaustive-checks-in-typescript

これまでのサンプルを書き換えると、次のような書き方ができるようになります。

satisfiesを使ったexhaustiveness check
type Direction = 'up' | 'down';

const move = (direction: Direction) => {
  switch (direction) {
    case 'up':
      console.log('Moving up');
      break;
    case 'down':
      console.log('Moving down');
      break;
    default:
      // (3)
      throw new Error(`Unexpected direction: ${direction satisfies never}`);
  }
};

case節の追加忘れで direction'left' 型になったりした場合は、direction satisfies never が条件を満たさなくなるので、型チェック時のエラーが発生します。従来の const exhaustiveCheck: never = direction の部分で確認していた、「default節のnever型の保証=switch文全体の網羅性の保証」を行なっているわけですね。

実際にユニオン型に 'left' を追加したら、エディタ上でどのように表示されるか見てみましょう。

satisfiesによる型チェック

これまでの方法と同様に、case節の網羅性の漏れに気がつけるようになっていますね。実行時には satisfiles はもうなくなっていますが、throw文は残るので、実行時の網羅性エラーは引き続き検出できます。

これによって、2行で書いていた部分が1行で済むようになりました。チェック機構としては従来のものと同じ機能を持っているので、安心して乗り換えられますね。azukiazusaさんマジありがとう。

まとめ

手癖で書いていたexhaustiveness checkを、satisfies演算子を使うことでより簡潔に書けることがわかりました。クセの矯正には少し時間がかかるかもしれませんが、確実にこちらのほうがタイプ数も少なくてオシャレなので、どんどん使っていきたいと思います。

Discussion