TypeScriptのexhaustiveness checkをスマートに書く
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
「それどころか、逆に分岐漏れがないときにのみ型エラーが起きてしまう。」ですかね。説明しているコードでは
action.type
がnever
になるでしょうし。偶然
type: "__invalid__"
が登場してしまう可能性への対応を考えてみました。unique symbol
を利用しているunique symbol
なのでreadonly
が必要unique symbol
を出力するためにas { type: unknown }
throw new Error(`Unknown type: ${action.type}`);
になる追記
シンプルに以下で良さそうな気がしてきました。
Playground
追記の
(action as { type: never }).type
は実はすべての構造体にタグ以外の余分なプロパティがある必要があって,が候補にあったりすると,すり抜けてしまいます
Playground
また,一個目の
action as { readonly type: unique symbol} as { type: unknown }).type}
は記事内にあるように @typescript-eslint/restrict-template-expressions を入れていると常にESLintエラーになってしまいますこの eslint ルールを有効にすれば default ケースを書かない前提で網羅性チェックが可能だと思うのですが、上の eslint ルールを使用しなくて済むことを重視した手法、あるいは他のメリットがあるという話だったりするのでしょうか?
冒頭に
とあるように,実行時にも検査したい,という前提があり, switch-exhaustiveness-check では実行時の検査はできないという点が異なるかと思います
メリットで言えば,私の意見ですが,
みたいなときに,早めに失敗してくれるのが単純に嬉しい (fail fast) と思っています
TypeScriptのsatisfies使ったら
"__invalid__"
のような決め打ちなしで行けないかなと試してみたら,以下のようなものができました(なにかしら @typescript-eslint/restrict-template-expressions に引っかからないものを0
の位置に置く必要はありますが,何をおいても,それが通り抜けていても検知できます)初心者質問で恐縮ですが、こちらの方法は
const exhaustiveCheck:never = action
を記述する方法と比べてどういった点で優れているのでしょうか