Edited at

TypeScript Handbook の Advanced Types をちょっとだけ掘り下げる - その5 Discriminated Unions

More than 1 year has passed since last update.


はじめに

本記事は TypeScript HandbookAdvanced Types に書かれているものをベースに、説明されている内容をこういう場合はどうなるのかといったことを付け加えて ちょっとだけ 掘り下げます。完全な翻訳ではなく、若干元の事例を改変しています。

今回は Discriminated Unions について掘り下げます。

その1 Type Guards and Differentiating Types は こちら

その2 Nullable types は こちら

その3 Type Aliases は こちら

その4 String Literal Types / Numeric Literal Types は こちら

その6 Index types は こちら

その7 Mapped types は こちら


Discriminated Unions

Discriminated Unions 、別名 Tagged UnionsAlgebraic Data Types として知られているパターンがあります。

TypeScript でこれを実現するには以下の三つが必要です。


  1. 共通のシングルトンなプロパティ - 判別子

  2. 上記のプロパティを持つ型のユニオンのエイリアス - ユニオン

  3. 共通プロパティに対する Type Guard

interface Square {

kind: 'square';
size: number;
}
interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}
interface Circle {
kind: 'circle';
radius: number;
}

どのインターフェイスにも kind というプロパティがあり、それぞれ異なる値を持っています。これが判別子もしくはタグと呼ばれるものになります。これ以外のプロパティは別々のものを持っています。

これをユニオンします。

type Shape = Square | Rectangle | Circle;

あとは Type Guard を使うことで、 Discriminated Union になります。

function area(s: Shape) {

switch (s.kind) {
case 'square': return s.size * s.size; // s is Square として認識される
case 'rectangle': return s.height * s.width; // s is Rectangle として認識される
case 'circle': return Math.PI * s.radius ** 2; // s is Circle として認識される
}
}


Exhaustiveness checking

ユニオンされた型すべてが網羅されていない場合にコンパイルエラーにしたい場合、どのようにすれば良いでしょうか。例えば Shape というユニオンに Triangle というのを足した場合、先ほどの関数を更新する必要がありますが、それを忘れてしまっていても現状ではコンパイル時にエラーが出ません。

type Shape = Square | Rectangle | Circle | Triangle;

function area(s: Shape) {
switch (s.kind) {
case 'square': return s.size * s.size;
case 'rectangle': return s.height * s.width;
case 'circle': return Math.PI * s.radius ** 2;
}
// triangle を処理していないのでコンパイルエラーにしたいが、特にエラーは出ない。
}

やり方は二つあります。一つ目の方法はコンパイラオプションの --strictNullChecks を有効にし、戻り値の型を定義することです。

function area(s: Shape): number { // コンパイルエラー : Function lacks ending return statement and return type does not include 'undefined'.

switch (s.kind) {
case 'square': return s.size * s.size;
case 'rectangle': return s.height * s.width;
case 'circle': return Math.PI * s.radius ** 2;
}
}

switch がすべてを網羅していないので、 コンパイラが undefined を戻す場合があることを認識します。戻り値が number に限定されていれば undefined を戻すことができないのでエラーにすることができます。

しかし、 --strictNullChecks を有効にできない古いコードのような場合にはこの方法は使えません。

二つ目の方法は never を利用する方法です。

function assertNever(x: never): never {

throw new Error('Unexpected object: ' + x);
}
function area(s: Shape) {
switch (s.kind) {
case 'square': return s.size * s.size;
case 'rectangle': return s.height * s.width;
case 'circle': return Math.PI * s.radius ** 2;
default: return assertNever(s); // 漏れている場合にエラーを起こす。
}
}

assertNeversnever かチェックします。 never は Type Guard で全パターン除外された後に残る型です。(Shape - Squre - Rectangle - Circle - Triangle = never) case の記述漏れがあった場合、 s はその漏れている型として認識される(ここでは Shape - Squre - Rectangle - Circle = Triangle)ので、 never 型の引数に代入できずコンパイルエラーになります。関数を別途用意する必要がありますが、より明示的な方法になります。

網羅チェックについてのここでの事例は Discriminated Unions を利用していますが、ユニオンであればすべて同じです。

Literal Types の場合。

type Numbers = 1 | 2 | 3 | 4 | 5 | 6;

function assertNever(x: never): never {
throw new Error('Unexpected object: ' + x);
}
function prize(n: Numbers) {
switch (n) {
case 1: return 1;
case 2: return 100;
case 3: return 50;
case 4: return 1000;
case 5: return 5;
default: assertNever(n); // 6 は never に代入できないためエラー
}
}

class の場合、 Type Guard に判別子を使うのではなく、 instanceof になりますが、これも同じようなことができます。

class Square {

size: number;
}
class Rectangle {
width: number;
height: number;
}
class Circle {
radius: number;
}
class Triangle {
width: number;
height: number;
}
type Shape = Square | Rectangle | Circle | Triangle;
function assertNever(x: never): never {
throw new Error('Unexpected object: ' + x);
}
function area(s: Shape) {
if (s instanceof Square) {
return s.size * s.size;
}
if (s instanceof Rectangle) {
return s.height * s.width;
}
if (s instanceof Circle) {
return Math.PI * s.radius ** 2;
}
assertNever(s); // 同様にエラー
}


まとめ

その1 にインターフェイスの Type Guard として以下のような関数を定義するやり方がありました。

function isSquare(s: Shape): s is Square {

return (s as Square).size !== undefined;
}

このやり方だとパターンが増えるたびに関数を追加しなくてはなりませんでしたが、 Discriminated Unions パターンでは共通のプロパティを一個増やすだけで済むので記述量を減らすことができます。 Type Guard での判別子の値の入力もエディタでの補完が効き、網羅チェックについても、いずれにせよ同じ Type Guard の仕組みに乗っかていて差異はないので、この書き方で書いた方が色々捗りそうです。