28
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita100万記事感謝祭!記事投稿キャンペーン開催のお知らせ

誤解されがちなnever型の危険性: 「存在しない」について

Last updated at Posted at 2025-01-19

never型はTypeScriptに存在する型のひとつで、「存在しない値の型」として説明されることが多いものです。

この説明は正しいのですが、誤解されることがあると感じました。そこで、今回はnever型に関する誤解を解くための記事を用意しました。ぜひこの記事を読んで、never型について正しく理解しているかチェックしてみてください。

never型とは

never型は、上述の通り、「存在しない値の型」です。言い換えれば「never型の値は存在しない」ということです。こんな簡潔な説明ですが、never型は奥深い性質を持っています。

まず、「never型の値は存在しない」というのがどういうことか理解しましょう。これは、どんな値もnever型には代入できない(never型の値ではない)ということです。このように、never型の変数には、何を代入しようとしても全てエラーになります。

let n: never;

// 全てエラー
n = 123;
n = "Hello";
n = true;
n = { hello: "world" };
n = null;

never型の応用は、多くの場合、いわゆる背理法で考えると理解しやすいかもしれません。つまり、「never型の値は存在しない」ということを言い換えれば、「never型の値が存在したならば矛盾」とも言えます。以下で説明する応用を、この考え方で見てみましょう。

never型を返す関数

実は、TypeScriptでは「never型を返す関数」を(asなどの危険な機能を使わずに)定義することができます。どうすればいいか考えてみましょう。

function returnNever(): never {
  // ?
}

まず、下のような関数はnever型を返す関数にはなりません。

function returnNever(): never {
  // これは型エラー
  console.log('Hello, world!');
}

一見すると「何もreturnしない=返り値が存在しない=never型!」と思われるかもしれませんが、これは違います。TypeScriptでは、何もreturnしない関数はundefinedを返す挙動になるため、何も返していないつもりでもundefinedという返り値が存在しているのです。

そのため、何も返さない関数の返り値の型は、返り値を使う意味がないことを表すvoidか、明示的にundefinedとするのが正解です。

function returnNever(): void {
  // これはOK
  console.log('Hello, world!');
}

function returnNever2(): undefined {
  // これもOK
  console.log('Hello, world!');
}

では、結局never型を返す関数はどうすれば作れるのでしょうか。ここで背理法の考えを取り入れましょう。

never型を返す関数returnNeverを呼び出して、返り値を得たと仮定しましょう。

const n = returnNever();
//    ^? const n: never

当然、型推論により変数nの型はnever型となります。すると、このコードでは、never型の値が変数nに入っていることになります。つまり、変数nの中にnever型の値が存在していることになります。

しかしながら、never型の値は存在しないのですから、ここで矛盾が生じています。

つまり、矛盾を解消するためには、returnNever()から返り値が得られてはいけないのです。ということで、never型を返すreturnNeverは次のように実装することができます。

function returnNever(): never {
  while (true) {}
}

こうすれば、const n = returnNever();としても、nに何らかの値が入ることはありませんね。つまり、コード上は変数nにnever型の値が入っているように見えますが、実際の挙動としてはnに値が入るということが起こり得ません。

言い換えれば、never型の値(never型の変数)が存在するということは、「実際にはこの変数には値が入らない」という意味になります。値が入ったら矛盾してしまうからですね。そして、変数に値が入らないということは、その変数までプログラムの実行が到達しないということです。

そのため、never型を返す関数は、上記のように「そもそも関数から出て来られない関数」となります。

ちなみに、VSCodeのようなエディタでは、never型を返す関数を呼び出した場合、それより後のコードが到達不可能コードとして判定され、このように半透明で表示されます。

returnNever関数を呼び出した後に書かれたconsole.logの呼び出しが半透明になっている。

この理由も、never型の意味を正しく知っていれば理解できるはずです。

なお、上の例ではnever型を返す関数の例として無限ループに陥る関数を紹介しましたが、他のパターンもあります。それは、必ず例外を発生させる関数です。次のような関数も、never型を返す関数として認められます。

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

この場合、例外という形である意味関数は終了していますが、正常終了ではありません。そのため、やはりconst n = returnNever();としても、変数nに何か値が入ることはありませんから、前述の説明とは矛盾しませんね。

繰り返しになりますが、never型の値が得られるということは矛盾ということです。そのため、コード上never型の値が取得できたように見える箇所は、実際にはそのコードは実行されないという意味になります。

一見すると複雑な法則に見えますが、これは「never型の値は存在しない」という性質と、背理法的な考え方だけがあれば導けるものです。

TypeScriptでは型情報を用いた最適化がありませんが、もし最適化をするコンパイラが現れたとしたら、「never型が得られているコードは到達不可能コードなので全部消す」のような最適化がされるかもしれません。

網羅性チェックとnever

neverの活用法として多くのTypeScriptエンジニアになじみ深いのは、網羅性チェック(exhaustiveness check)をするときではないでしょうか。

次のサンプルコードは、いわゆるタグ付きユニオンでOption<T>を定義し、それに対するmapOption関数を実装するコードです。

type Option<T> = {
  type: "Some";
  value: T;
} | {
  type: "None";
}

function mapOption<T, U>(
  option: Option<T>,
  mapper: (value: T) => U,
): Option<U> {
  switch (option.type) {
    case "Some": {
      return {
        type: "Some",
        value: mapper(option.value),
      };
    }
    case "None": {
      return {
        type: "None",
      }
    }
    default: {
      // exhaustiveness check
      const _: never = option;
      throw new Error("Unknown type");
    }
  }
}

ポイントは、コメントでexhaustiveness checkと書いてあるところです。TypeScriptの型推論(型の絞り込み)機構により、この位置では変数optionはnever型となっています。そのことを「const _: never = option;」という文で明示的にチェックするのがexhaustiveness checkです。

never型の値は存在しないはずなのに、TypeScriptが「ここではoptionはnever型だな」と判断しているのはなぜでしょうか。これは、先ほどと同じ考え方で理解できます。

繰り返しますが、「never型の値が存在するということは、そのコードは実際には実行されない」ということです。このコードではdefault節の中でoptionがnever型になっています。つまり、TypeScriptが「このdefault節は実際には実行されない」と判断したということです。

このコードでは、型定義上option.type"Some"または"None"しかありえないことになっています。それらは上2つのcase節でカバーされているはずですから、defaultに入ることは無いはずですね。

このように、「このコードは実行されないはずだ」という意図をnever型を使ってTypeScriptコード中に表現することができ、型チェックによりそれを保証することができます。これがnever型を用いるexhaustiveness checkです。

never型の性質は「never型の値は存在しない」ことですが、それを応用した形である「never型の値が得られているコードは、実際には実行されない」という性質が実際には活用されています。

never型の別の定義

never型の性質は「never型の値は存在しない」ということですが、もうすこし理論的な用語を使って「never型はボトム型である」と言うこともできます。Wikipediaのボトム型のページを見ると、この記事でこれまで述べてきたようなことが簡潔に説明されており、TypeScript以外の類例も見ることができます。

その記事を見ると、ボトム型は全ての型の部分型であるとされており、これはnever型にも当てはまります。言い換えれば、never型の値は、どんな型の値としても扱うことができるということです。

function useNever(n: never) {
  const num: number = n;
  const str: string = n;
  const obj: { hello: string } = n;

  console.log(obj.hello.concat(" ", str, "!").repeat(num));
}

このように、never型の値nが得られたとすると、それをnumber型の変数に入れることができるし、string型としても{ hello: string }型としても扱いやりたい放題できます。これはnever型が、number, string, { hello: string }といった型の部分型であるゆえの挙動です。

「never型は全ての型の部分型である」というのをnever型の定義にしても問題ありません。ランタイムのJavaScriptには、この性質を満たす値が存在しないからです。

上のコードを大真面目に解釈すると、「nは数値として扱うことができ、文字列としても扱うことができ、オブジェクトとしても扱うことができる値である」と読めます。しかし、JavaScriptにそんな値は存在しません。つまり、never型の「全ての型の部分型である」という性質を満たす値が実際には存在しないので、結果として「never型の値は存在しない」と言えるのです。

言い換えると、上のuseNever関数は型エラーが起きませんが、実際にこんなコードを実行してもうまく動くはずがありません。それでも型エラーが起きないのは、useNever関数を呼び出すのが不可能だからです。呼び出すためにはnever型の値を引数に渡す必要がありますが、never型の値を用意することは不可能です。つまり、実際にはuseNeverが呼び出されないはずだから、useNeverの中でnever型の値を使って好き勝手することができるのです。

これは、先ほどの「never型の値が得られているコードは実際には実行されない」と同じ話です。never型の値を用意できない以上、useNever関数の中身が実際に実行されることは無いはずです。

never型の危険性

では、記事タイトルにあるnever型の危険性に話を移しましょう。

ここまでこの記事を読んでわかるとおり、never型に関連する型チェックは「never型の値が存在しない」という性質を前提に作られています。

そのため、asなどの型チェックをすり抜ける手段を使ってnever型の値を発生させてしまうのはとても危険なことです。

// 呼び出せないはずの関数を呼び出せてしまう!
useNever({} as never);

前述のように、never型の値は全ての型の部分型であるという性質を活かしてほとんど何でもできてしまいますから、anyに匹敵する危険性を持ちます。

TypeScriptにおけるasの正しい使い方は、TypeScriptが推論しきれなくて実際の型とは違う(あるいは実際の型より弱い)結果になってしまうところを、実際の挙動に一致した正しい型に直してあげることです。それ以外の用途で使う、つまり「嘘をつく」ために使うことは、型チェックの利点を殺してしまうため良くありません。

never型の性質を満たすような実際の値はJavaScriptには存在しませんから、as neverはほぼ100%嘘です。as neverが正当化される場合があるとすれば、「TypeScriptは理解してくれないけど、実際にはここのコードは到達不可能なんだよ」のような意図を示したい場合です。

要するに、never型は「never型の値は存在しない」という大前提に従って取り扱うべきものであり、asなどを使ってそれを逸脱するような行為は型安全性を脅かすためやってはいけないということです。「嘘をつく」ために使われた場合、neverが全ての型の部分型であるという性質のため、その嘘の危険性は特に大きいものとなります。

危ないBranded Type

ところで、筆者はBranded Typeについて先日記事を書きました。

しかし、この記事で触れなかったBranded Typeのやり方として、下のようなやり方をされることもあるようです。

type UserId = string & { __userIdBrand: never };

これは上の記事で触れた良くないやり方の亜種であり、never型が使われているのが特徴です。

筆者はこれはとても良くないと思っており、このやり方がされる一因にnever型に対する誤解があると考えたためこの記事を書くことにしました。

このようなUserId型の値は、ランタイムにはただの文字列です。つまり{ __userIdBrand: never }というのは型上だけしか存在しない、実際には存在しないものです。

ここでneverを使う理由として、neverは「存在しない」を表す型だからとされています。筆者の考えでは、これはnever型に対する誤解に基づくものです。

上記の筆者の記事では、unique symbolを使わず「__userIdBrand」のようにするのは「嘘だから」良くないと説明しました。そして、この記事では、never型の値が存在すると嘘をつくのは、嘘の中でも特に危険な嘘であることを説明しました。

実際、このように定義されたUserIdを使うと、never型の値が得られてしまい、型安全性がお亡くなりになります。as neverのように直接的にnever型の値を発生させるだけでなく、{ __userIdBrand: never }のように「never型を含むオブジェクト型」を発生させるのも、危険度では同等です。オブジェクトでラップしたとしてもnever型の値が実際にあるという意味では違いがありませんからね。

function useUserId(id: UserId) {
  const n: never = id.__userIdBrand;
  // never型の値が手に入った!
  // 奇 跡 の カ ー ニ バ ル 開 幕 (死語)
}

要するに、never型の「値が存在しない」という性質を、「ランタイムに存在しないものを型の上ではあるように見せる」という用途で使うのは間違いです。むしろ逆で、「値が存在しない」という性質はnever型に関わる推論の基礎となる性質ですから、それを脅かす嘘(never型が存在するという嘘)をつくことは何としても避けるべきです。

筆者が書いたBranded Typeの記事では、(__userIdBrand自体も良くないとはいえ)このパターンでは{ __userIdBrand: unknown }としたバージョンを紹介しています。ここでnever型ではなくunknown型を使っているのは、これがnever型の対極に位置する型(トップ型)であり、それゆえに嘘としての危険性が最も低いからです。

まとめ

never型は「値が存在しない型」という非常に特異な性質を持っていますが、「存在しない」という言葉の意味を正しく解釈できないと、理解を誤ってしまう恐れがあります。そこで、この記事ではnever型の値が「存在しない」とはどういうことなのか、正しい理解ができるように丁寧に説明しました。

never型は正しく使えばTypeScriptにおける型安全性を向上させるツールにもなります。その性質を正しく理解し、型推論や網羅性チェックで活用することは、堅牢でメンテナブルなコードを作るうえで非常に有効です。

一方で、この性質を誤解したり、不適切に扱ったりすると、never型はコードの型安全性を大きく損なう危険な存在になり得ます。特に、asを利用してnever型の値を意図的に発生させるのは避けるべきです。

28
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?