皆さん、こんにちは!!
ちょっと社内で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と組み合わせてより良き型安全の世界を楽しんでください。