Kgtkr's Blog

TypeScriptで安全にランタイム型チェックを行う〜網羅性チェックとnever型〜

2020/02/06
typescript

以下のコードを見てください。

type HogeUnion = 1 | 2;

function printHogeUnion(x: HogeUnion) {
  console.log(x);
}

HogeUnion型、つまり1 | 2であるxを受け取って出力するだけの単純な関数printHogeUnionを定義しています。何も問題がないコードです。

もしTSの型の健全性が壊されてx1 | 2以外の値が入ってきた時にすぐ気付けるようにランタイム型チェックを行いたくなったとしましょう。例えばprintHogeUnion(0 as any)を実行すると0を出力するのではなく例外を投げるといった具合です。この時unreachable関数を定義し、printHogeUnionにランタイム型チェックのコードを追加して以下のようになります。

function unreachable(): never {
  throw new Error("unreachable");
}

function printHogeUnion(x: HogeUnion) {
  if (x !== 1 && x !== 2) {
    unreachable();
  }

  console.log(x);
}

このコードは確かに正しいです。しかし問題もあります。それは仕様変更にとても弱いという事です。例えばHogeUnionの型を1 | 2 | 3にし、printHogeUnionの修正を行わないとどうなるでしょう。

type HogeUnion = 1 | 2 | 3;

function printHogeUnion(x: HogeUnion) {
  if (x !== 1 && x !== 2) {
    unreachable();
  }

  console.log(x);
}

このコードはコンパイルが通ります。しかしprintHogeUnionにあるランタイム型チェックの処理の修正を忘れているのでprintHogeUnion(3)を実行すると例外が投げられます。これはバグです。この程度ならすぐに気付けますがもしHogeUnionを使っておりランタイム型チェックをこのようにしている関数がたくさんあれば修正漏れが発生しそうです。
このような場合、never型を受け取って例外を投げる関数safeUnreachableを定義して使うと上手くいきます。

function safeUnreachable(_x: never): never {
  throw new Error("unreachable");
}

type HogeUnion = 1 | 2 | 3;

function printHogeUnion(x: HogeUnion) {
  if (x !== 1 && x !== 2) {
    safeUnreachable(x); // コンパイルエラー
  }

  console.log(x);
}

こうするとx3型なのでコンパイルエラーになってランタイム型チェックの修正漏れにすぐ気づくことができます。printHogeUnionを以下のように修正するとコンパイルが通ります。

function printHogeUnion(x: HogeUnion) {
  if (x !== 1 && x !== 2 && x !== 3) {
    safeUnreachable(x);
  }

  console.log(x);
}

never型の変数は型の健全性が保たれている限り値が存在しません。つまりnever型の値が出てくるコードには到達しません。これは到達可能なコードであればnever型の値を作ることができないということでもあります。
つまり仕様変更などでそのコードに到達可能になればコンパイルエラーが発生しすぐにバグに気づくことができます。これで安全にランタイム型チェックが行えるようになりました。

ちなみにこれは以下のような応用も可能です。

function foo(x: 1 | 2) {
  if(x === 1) {
    console.log("a");
    return;
  }

  if(x === 2) {
    console.log("b");
    return;
  }

  safeUnreachable(x);
}

こうすることでもしx: 1 | 2 | 3になったときにすぐ修正漏れに気づくことができます。