🧐

TypeScriptのexhaustiveness checkをスマートに書く

2022/08/14に公開8

TypeScriptではデザインパターンとしてtagged unionによる直和がよく使われます。このときパターンマッチに相当する処理はswitchで行われますが、そこで直和に対する分岐が網羅的であることの保証を実行時と型検査時の両方で賢く行う方法がこれまでも模索されてきました。

今回、ヘルパー関数を導入せずにいくつかの問題を同時に解決する賢い方法を思い付いたので共有します。

コード

これだけです。

// switch (action.type) { ... default:
  throw new Error(`Unknown type: ${(action as { type: "__invalid__" }).type}`);
// .. }

以下、より詳しく説明します。

問題

TypeScriptではオブジェクトに type プロパティーを用意し、決まった文字列を入れることで直和を実現するというデザインパターンが広く使われています。このとき、パターンマッチに相当する処理はswitchで行われます。

// GetまたはPutのどちらかをあらわす型
type Action = GetAction | PutAction;
type GetAction = {
  type: "Get";
  name: string;
};
type PutAction = {
  type: "Put";
  name: string;
  value: string;
};

function act(action: Action) {
  // Getの場合とPutの場合で場合分けする
  switch (action.type) {
    case "Get":
      console.log(`get(${action.name})`);
      break;
    case "Put":
      console.log(`put(${action.name}, ${action.value})`);
      break;
  }
}

しかし、このようにswitchを使った場合、以下のような問題があります。

  • あとで Action を更新してアクションの種別を増やしたときに分岐漏れが発生するが、そのことに型検査時・実行時に気付けない。
  • 何らかのバグにより意図しないオブジェクトが混入した場合にも分岐漏れが発生するが、そのことに実行時に気付けない。

そこで、型検査時と実行時の両方で、分岐の網羅性をチェックする方法がこれまで模索されてきました。

素朴な実行時チェック

素朴な方法としては以下のようなコードが考えられます。

  // Getの場合とPutの場合で場合分けする
  switch (action.type) {
    case "Get": /* ... */
    case "Put": /* ... */
    default:
      throw new Error(`Unknown type: ${action.type}`);
  }

これには次のような問題があります。

  • 分岐漏れがあっても型エラーにならない。
  • それどこか、逆に分岐漏れがあるときにのみ型エラーが起きてしまう。

後者は action のフロー型として never が推論されてしまうことで発生します。理屈の上では never 型の値にはどのような操作をしても never になるはずですが、TypeScriptではヒューマンエラーを防止するためか never に対する操作の一部を禁止しています。 action.type はまさにそのような例として特別に弾かれてしまっているというわけです。

既存の方法

式の値が never であることをTypeScriptに別途チェックさせる方法が今まで知られていました。たとえば以下のような方法です。

function assertNever(_x: never) {}
assertNever(action);

ほかにも const の型宣言を使ったり、 never を受けとるエラーサブクラスを定義するなどの方法が知られています。

提案手法

今回提案する方法では以下のようなコードを書きます。

throw new Error(`Unknown type: ${(action as { type: "__invalid__" }).type}`);

コード全体では以下のような感じになります。

type Action = GetAction | PutAction;
type GetAction = {
  type: "Get";
  name: string;
};
type PutAction = {
  type: "Put";
  name: string;
  value: string;
};

function act(action: Action) {
  switch (action.type) {
    case "Get":
      console.log(`get(${action.name})`);
      break;
    case "Put":
      console.log(`put(${action.name}, ${action.value})`);
      break;
    default:
      throw new Error(`Unknown type: ${(action as { type: "__invalid__" }).type}`);
  }
}

網羅的である場合

実際にswitchが網羅的だった場合、この位置での action のフロー型は never に推論されます。すると、

action as { type: "__invalid__" }

never から { type: "__invalid__" } へのアップキャストとなり許可されます。また意味論的にも、この部分は到達不能コードであるのでこのような型アサーションは妥当です。

結果として、

(action as { type: "__invalid__" }).type

"__invalid__" という型をもちます。

TypeScript-ESLintの @typescript-eslint/restrict-template-expressions ルールでテンプレート文字列内の型を制限している場合も、これならばエラーにならないはずです。

網羅的でない場合

網羅的でない場合、たとえば type: "Delete" の場合分けが必要なのに忘れていた場合、 action のフロー型は

{ type: "Delete", ... }

になります。すると、

action as { type: "__invalid__" }

{ type: "Delete", ... } から { type: "__invalid__" } へのキャストとなります。

TypeScriptの as は基本的には型検査を無視して型を強制する代物ですが、明らかに怪しいものについてはコンパイルエラーになります。今回の例ではアップキャストでもダウンキャストでもなく、入力型と出力型は共通部分を持たないので、怪しいキャストとしてエラーになるというわけです。

実行時の挙動

実行時の挙動は「素朴な実行時チェック」の節で示したものと同じです。

まとめ

TypeScriptでtagged unionに対するswitchの網羅性チェックをするための、ヘルパー不要の賢い方法を提案しました。

Discussion

YAMAMOTO YujiYAMAMOTO Yuji

それどこか、逆に分岐漏れがあるときにのみ型エラーが起きてしまう。

「それどころか、逆に分岐漏れがないときにのみ型エラーが起きてしまう。」ですかね。説明しているコードでは action.typenever になるでしょうし。

Ryo Ota - @nwtgckRyo Ota - @nwtgck

偶然 type: "__invalid__" が登場してしまう可能性への対応を考えてみました。

throw new Error(`Unknown type: ${(action as { readonly type: unique symbol} as { type: unknown }).type}`);

追記

シンプルに以下で良さそうな気がしてきました。

throw new Error(`Unknown type: ${(action as { type: never }).type}`);

Playground

lumaluma

追記の (action as { type: never }).type は実はすべての構造体にタグ以外の余分なプロパティがある必要があって,

type HeadAction = {
  type: "Head";
};

が候補にあったりすると,すり抜けてしまいます

Playground

Hideaki NoshiroHideaki Noshiro

https://typescript-eslint.io/rules/switch-exhaustiveness-check/

この eslint ルールを有効にすれば default ケースを書かない前提で網羅性チェックが可能だと思うのですが、上の eslint ルールを使用しなくて済むことを重視した手法、あるいは他のメリットがあるという話だったりするのでしょうか?

lumaluma

冒頭に

分岐が網羅的であることの保証を実行時と型検査時の両方で賢く行う方法

とあるように,実行時にも検査したい,という前提があり, switch-exhaustiveness-check では実行時の検査はできないという点が異なるかと思います

メリットで言えば,私の意見ですが,

  • 型が付いてるものの,ユーザーインプットだったり特殊な状況で関係ないものが来る,もしくは関連サービスがアップデートで新たな構造体が入ってくるような変更をして,型で想定しないものが実行時に到達しうる(新しいイベントタイプができました,とか)
  • HMR等で型チェック/ESLintが通ってない状態でもトランスパイルしてとりあえず動かすときに到達しうる

みたいなときに,早めに失敗してくれるのが単純に嬉しい (fail fast) と思っています

lumaluma

TypeScriptのsatisfies使ったら "__invalid__" のような決め打ちなしで行けないかなと試してみたら,以下のようなものができました(なにかしら @typescript-eslint/restrict-template-expressions に引っかからないものを 0 の位置に置く必要はありますが,何をおいても,それが通り抜けていても検知できます)

(action satisfies never as { type: 0 }).type
geDemgeDem

初心者質問で恐縮ですが、こちらの方法は
const exhaustiveCheck:never = action
を記述する方法と比べてどういった点で優れているのでしょうか