【入門編】関数型における「Never」型を活用した、網羅的な引数チェックの実装 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!TypeScriptの型システムの世界へようこそ。
フロントエンドからバックエンドまで、型が守ってくれる安心感を知ってしまうと、もう普通のJavaScriptには戻れなくなっちゃいますよね。

今回は、TypeScriptの型システムの中でも、特に「おっ、これ使いこなせるとカッコいいな!」と一目置かれるようになる`never`型を使った網羅性チェック(Exhaustiveness Checking)について解説していきます。

「他の言語から来たんだけど、`never`ってなんだか怖そう…」
「Union型の分岐漏れを、実行時じゃなくてコンパイル時に検知したい!」

そんな疑問や悩みを抱えている方に向けて、先輩エンジニアが優しく、かつ本質的な部分まで噛み砕いてお伝えしますね。ここをクリアすれば、あなたのTypeScriptスキルは一段上のステージに進めますよ。バッチリマスターしていきましょう!

—

1. Union型と条件分岐の「うっかり漏れ」という恐怖

まずは、私たちが普段よく書くコードを思い浮かべてみてください。
例えば、Webアプリのユーザー権限(ロール)によって処理を切り替える次のようなUnion型があるとします。

type UserRole = ‘admin’ | ‘editor’ | ‘viewer’;

この `UserRole` に応じたメッセージを返す関数を、`switch`文を使って書いてみましょう。

function getWelcomeMessage(role: UserRole): string {
switch (role) {
case ‘admin’:
return ‘管理者としてログイン中’;
case ‘editor’:
return ‘編集者としてログイン中’;
case ‘viewer’:
return ‘閲覧者としてログイン中’;
default:
return ‘ゲスト’;
}
}

一見、綺麗に書けているように見えますよね。でも、開発が進んで要件が変わり、新しいロール `subscriber` が追加されたらどうなるでしょうか?

type UserRole = ‘admin’ | ‘editor’ | ‘viewer’ | ‘subscriber’; // ← 追加!

この時、先ほどの `getWelcomeMessage` 関数を修正し忘れても、TypeScriptのコンパイラはエラーを出してくれません。 なぜなら、`default` 句があるおかげで「どのケースにも当てはまらないなら ‘ゲスト’ を返せばいいや」とコンパイラが解釈してしまうからです。

結果として、新しく追加された `subscriber` の人がログインした瞬間に「ゲスト」と表示されてしまう……。こんなバグ、現場でやったら冷や汗ものですよね。

—

2. 救世主登場! `never` 型による網羅性チェックとは?

ここで登場するのが、TypeScriptの型システムにおける最強の守護神 `never` 型 です。

`never` 型の本来の意味を覚えていますか?
それは「絶対に起こり得ない値の型」です。何も代入できないし、何も存在しない、型宇宙の「虚無」を表します。

この `never` の性質を利用して、「すべてのパターンを処理し終えたら、ここ(`default`句など)には絶対に到達しないはずだよね?」というアサーション(表明)をコンパイラに突きつけることができます。

実際のコードを見てみましょう。

type UserRole = ‘admin’ | ‘editor’ | ‘viewer’;

// 存在しないことをコンパイラに保証させるためのヘルパー関数
function assertNever(x: never): never {
throw new Error(`予期せぬ値が渡されました: ${JSON.stringify(x)}`);
}

function getWelcomeMessage(role: UserRole): string {
switch (role) {
case ‘admin’:
return ‘管理者としてログイン中’;
case ‘editor’:
return ‘編集者としてログイン中’;
case ‘viewer’:
return ‘閲覧者としてログイン中’;
default:
// ▼ ここがポイント!すべてのケースが網羅されていれば、
// 这里的 role は 「never型」 に縮小(Narrowing)されているはず。
return assertNever(role);
}
}

このコードの頭の中はどうなっている?(型評価の裏側)

コンパイラがこのコードをどう読んでいるか、頭の中でトレースしてみましょう。

1. `switch (role)` の中で、`’admin’`, `’editor’`, `’viewer’` のケースがそれぞれ処理されます。
2. `default` 句に到達した時点で、`UserRole` のうち、上のケースでまだ処理されていない残りカス(余った型)が何であるかが計算されます。
3. すべてのケースが網羅されていれば、残った型はもうありません。つまり、`role` の型は `never` になります。
4. `assertNever` 関数は引数に `never` 型を要求するため、型が `never` に縮小された `default` 句の中で `assertNever(role)` を呼ぶことは、TypeScriptの型チェックを綺麗に通過します。

—

3. もし「網羅漏れ」があったらどうなる?(コンパイルエラーの発生)

では先ほどと同様に、`UserRole` に `subscriber` を追加してみましょう。

type UserRole = ‘admin’ | ‘editor’ | ‘viewer’ | ‘subscriber’; // 追加!

function getWelcomeMessage(role: UserRole): string {
switch (role) {
case ‘admin’:
return ‘管理者としてログイン中’;
case ‘editor’:
return ‘編集者としてログイン中’;
case ‘viewer’:
return ‘閲覧者としてログイン中’;
default:
// ここでエラーが発生する!
return assertNever(role);
// ❌ コンパイルエラー:
// 「型 ‘”subscriber”‘ の引数を型 ‘never’ のパラメーターに割り当てることはできません。」
}
}

やったー!見事にコンパイルエラーになりましたね!

コンパイラは、「おいおい、`subscriber` というパターンがまだ `switch` 文で処理されていないぞ!だから `default` に流れてきた `role` は `never` ではなく `”subscriber”` 型のままだ。`assertNever` に渡すのは型違反だ!」と、慈悲なく教えてくれます。

これにより、「コードを修正し忘れてデプロイし、本番環境でバグる」という未来を、ビルド(コンパイル)の瞬間に完全に防ぐことができるのです。これが `never` 型を活用した網羅性チェックの真髄です。

—

4. 陥りやすいポイントとスマートに書くコツ

初学者のうちは、このイディオムを書くときにいくつか躓きやすいポイントがあります。最後に、実務で役立つコツをいくつかシェアしますね。

① `default` 句を忘れない

網羅性チェックは、基本的に `switch` 文の `default` 句や、条件分岐の最後の「その他」の枝で行います。すべてのケースを `case` で網羅したつもりでも、TypeScriptが「本当に網羅されているか」を確信するためには、最後の砦(`default`)が必要です。

② `any` や `as` で型エラーを無理やり消さない

もしコンパイルエラーが出たときに、面倒くさくなって `assertNever(role as never)` のようにキャストしてエラーを握り潰してしまう人がたまにいますが、これは絶対にNGです。TypeScriptの神様がせっかく警告してくれているのですから、素直に `switch` 文に新しい `case` を追加してあげましょう。

—

まとめ:型は私たちの「最高のテスト駆動開発パートナー」

今回は、`never` 型を用いた網羅的な引数チェック(Exhaustiveness Checking)について解説しました。

  • `never` 型 は「何も起こり得ない(存在しない)」ことを表す型。
  • Union型のすべてのパターンを処理し切ったとき、残余の型は `never` に収束する。
  • これを利用して `assertNever` 関数に渡すことで、将来の仕様変更に伴う分岐漏れをコンパイル時に100%検知できる。

「テストを書かなくても、型がテストの代わりをしてくれる」——TypeScriptの醍醐味はまさにここにあります。
このテクニックをマスターすれば、大規模なリファクタリングも怖くなくなりますよ。ぜひ明日の開発から、あなたのコードに取り入れてみてくださいね!

それでは、良きTypeScriptライフを!

タイトルとURLをコピーしました