皆さん、こんにちは!!
ちょっと社内でScalaのsealed pattern matchみたいに全ての型を網羅できるか検証する方法が話題になったのでまとめておきます。
元ネタに関しては上記2つのissueをご参照ください。
ユースケース
例えば下記のようにA | BというUnion Typeを全網羅するswitchを書いたときに、このswitchが全網羅されていることを担保したい時がありませんか?
仮にA | B | CのようにUnion Typeが追加されたさいにCが新しいケースとして追加されるべきなのに、そこの対応を漏らしてdefaultになってしまっていたとかよくある話だと思います。
type A = { type: 'A' };
type B = { type: 'B' };
declare var o: A | B;
switch (o.type) {
case 'A':
// ...
break;
case 'B':
// ...
break;
default: // ...
}
そういった問題を防ぐためにsealedして新しい型が追加されたときにケースの追加漏れを防ぎます。
sealed case
type A = { type: 'A' };
type B = { type: 'B' };
type C = { type: 'C' };
declare var o: A | B;
switch (o.type) {
case 'A':
console.log(`type A == ${o.type}`);
break;
case 'B':
console.log(`type B == ${o.type}`);
break;
default: (o:empty)
}
やり方は簡単でdefaultケースにて(o:empty)とemptyにキャストするだけです。簡単ですね!!
仮にdeclare var o: A | B; | CのようにCを追加すると、emptyへのキャストでエラーが出るようになります。
何故、このような動きをするのかというとType Refinementが関わってきます。
今回の場合で言うとcase 'A'のステートメントではoはA型としてType Refinementされます。
逆に言えばその他のswitch内のステートメントではA | BからAを除いたB型であるといえます。
次のcase 'B'のステートメントではoはB型としてType Refinementされ、最後に残ったdefaultのステートメントではA | BからAとBを除いた何にもマッチしない型(=empty)であるとType Refinementされることになります。
そのため、defaultのステートメントではo:emptyというキャストが成功することになります。
しかし、A | B | Cの場合はどうでしょうか?
A | B | CからAとBを除いた型はCになるため(o:empty)でCをemptyにキャストしようとして失敗します。
蛇足
蛇足話ですが、A | BからAを除外とか言うと$Diff<A, B>のことを思い出しますが、$Diff<A, B>を使っても同じようなことはできません。
$Diff<A, B>はAとBにObject型を指定して、AのプロパティからBのプロパティを除外するための型なので、同じことはできません。
さらに蛇足話ですが、A & Bの場合はどちらも満たす型なので、常に一つめのケースにマッチします。
(というか今回のケースだとA & Bを満たす型の値は絶対に作れないのでアレですけど)
まとめ
と言う訳でいかがでしたでしょうか。
昨今のJSではよくstring literalのenumを作ることが多いので結構登場頻度の多い技術かと思います。
$Keysや$Values、$ElementTypeなどenumと組み合わせて使えるUtility Typeもあるので、sealedと組み合わせてより良き型安全の世界を楽しんでください。