TypeScriptの網羅性チェックがバグのカテゴリ全体を排除する方法

Table of Contents
never type
never type
exhaustive check
exhaustive check
type guard
type guard
discriminated union
discriminated union
TypeScriptの網羅的チェックがいかにバグのカテゴリ全体を排除するか
TypeScriptはコンパイル時に多くのバグを捕捉します。しかし、TypeScriptを単なる便利なアシスタントから容赦ない監査役に変えるパターンが1つあります。それがnever型を使った網羅的チェックです。その違いは、switchでケースが欠落していても、「これは未定義かもしれない」という曖昧な警告ではなく、忘れた正確な行でハードエラーが発生することです。
解決する問題
進化するコードベースでは、enumのようなswitchステートメントはすべて時限爆弾です。3つのケースに対するハンドラを記述します。3ヶ月後、誰かが4番目のバリアントを追加します。コンパイルは通ります。テストもパスします。本番環境では新しいケースが黙って無視されます。顧客が報告するまでエラーは発生しません。
網羅的チェックがその解決策です。これにより、コンパイラは単なるスペルチェッカーではなく、あなたのレビュー担当者になります。
never型の実践
TypeScriptのnever型は、決して発生しない値を表します。常に例外をスローする関数はneverを返します。網羅的チェックのパターンはこれを悪用します。TypeScriptがブランチがnever型であると考えているのに、その結果を別のものに割り当てようとすると、毎回エラーが発生します。
これがコアパターンです。
type Status = "idle" | "loading" | "success" | "error";
function renderStatus(status: Status): string {
switch (status) {
case "idle":
return "Waiting...";
case "loading":
return "Loading...";
case "success":
return "Done.";
case "error":
return "Something went wrong.";
default:
// The exhaustiveness check
const _exhaustive: never = status;
return _exhaustive;
}
} ```
The assignment `const _exhaustive: never = status` is unreachable by design. If you add a new `Status` member and forget to add a `case` above, TypeScript assigns `"new-status"` to `_exhaustive` and immediately reports:
Type '"new-status"' is not assignable to type 'never'.
It does this at compile time. No tests needed for this particular bug category.
## A Real-World Example
<Callout type="code" title="API Response Handler">
```typescript
type ApiResponse<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: ApiError };
function showResponse<T>(response: ApiResponse<T>) {
switch (response.status) {
case "idle":
return <EmptyState />;
case "loading":
return <Spinner />;
case "success":
return <DataView data={response.data} />;
case "error":
return <ErrorBanner message={response.error.message} />;
default:
const _exhaustive: never = response;
return _exhaustive;
}
} ```
</Callout>
各ブランチは識別された共用体を正しく絞り込みます。もし誰かが後で`"retry"`を`ApiResponse`に追加し、`case "retry"`ブランチの追加を忘れた場合、`default`ケースはその正確な行で壊れます。ランタイムでの予期せぬ事態は起こりません。
## ヘルパー関数
多くのコードベースでは、インライン変数宣言よりも名前付き関数を好みます。読みやすく、問題が発生したときに検査する場所が1つにまとまります。
```typescript
function assertNever(
x: never,
message = "Unexpected exhaustive case"
): never {
throw new Error(`${message}: ${JSON.stringify(x)}`);
} ```
Usage:
```typescript
function processState(state: AppState) {
switch (state.type) {
case "init": return init(state);
case "fetch": return fetchData(state);
case "render": return render(state);
default: return assertNever(state, "Unhandled AppState");
}
} ```
関数の戻り値の型は`never`なので、`state`が実際に`never`でない場合、TypeScriptはその呼び出しのコンパイルを拒否します。この拒否がバグのシグナルです。
## なぜ単に例外をスローするデフォルトではだめなのか?
素朴なアプローチは、`default`から`never`チェックなしで例外をスローすることです。
```typescript
default:
throw new Error(`Unhandled status: ${status}`);
これは似ているように見えますが、決定的なギャップがあります。新しいStatus値があっても黙ってコンパイルされます。ランタイムエラーは、そのパスが実際に本番環境でヒットしたときにのみ発生します。網羅的チェックは、誰かがデプロイする前に、コンパイル時に発生します。
| Aspect | Naive default throw | Exhaustive never check |
|---|---|---|
| Adding a new case | Compiles silently | Fails at compile time |
| Error detection | Surfaces at runtime in prod | Surfaces in editor, before commit |
| Type evolution | Easy to forget | Enforced by type system |
| IDE support | No signal | Highlights exact line |
識別された共用体との使用
網羅的チェックは、大規模な識別された共用体で最も輝きます。ステートマシン、ASTノード、イベントペイロードが一般的なターゲットです。
type AuthEvent =
| { kind: "login"; username: string }
| { kind: "logout" }
| { kind: "token_refresh"; token: string }
| { kind: "session_expired"; reason: string };
function logAuthEvent(event: AuthEvent) {
switch (event.kind) {
case "login":
console.log(`${event.username} logged in`);
break;
case "logout":
console.log("User logged out");
break;
case "token_refresh":
console.log(`Token refreshed, ends ${event.token.slice(-4)}`);
break;
case "session_expired":
console.log(`Session expired: ${event.reason}`);
break;
default:
const _exhaustive: never = event;
return _exhaustive;
}
} ```
Adding `{ kind: "mfa_challenge" }` without a corresponding `case` breaks compilation immediately. This is the behavior you want in security-sensitive code where silently ignoring a case can mean silently bypassing a check.
## Exhaustiveness in `if/else` Chains
```typescript
function getRoleName(role: UserRole): string {
if (role === "admin") return "Administrator";
if (role === "editor") return "Content Editor";
if (role === "viewer") return "Read-only Viewer";
// Exhaustiveness check at the end
const _exhaustive: never = role;
return _exhaustive;
} ```
このパターンは`if/else`チェーンでも同じように機能します。チェーンの最後に「これ以上ここに到達するものはないはずだ」という番兵として自然に読み取れます。
## ツールとの互換性
- TypeScript 5.x(すべての現行バージョン)— `never`代入チェックは初期バージョンから安定しています。
- 厳格モードを推奨:網羅的チェックは、到達不能なブランチが実際に`never`であることをコンパイラが強制することに依存します。厳格モードなしでも機能しますが、`noImplicitAny`オフモードではシグナルが弱くなります。
- ESLint:`@typescript-eslint/switch-exhaustiveness-check`ルールは、手動の`never`変数なしで、ほとんどの識別された共用体のケースをカバーします。利用可能な場合はESLintルールを優先し、古いツールチェーンでは手動パターンを保持してください。
- IDE:VSCode、WebStorm、Zedはすべて、ビルドステップなしで、代入サイトで不一致をインラインで表示します。
## 制限事項とエッジケース
- `never`代入トリックは、`Map`コールバックや三項演算子内では直接機能しません。これらのコンテキストでは名前付きヘルパー関数を優先してください。
- `switch`がマップされた型内の共用体をカバーする場合、網羅的チェックはプロパティアクセスで偽陽性を生成する可能性があります。エラーメッセージを読みやすくするために、チェックを別の関数に分割してください。
- 網羅的チェックは、非常に大規模な共用体(100以上のバリアント)の場合、コンパイル時にわずかなコストを追加します。多くのそのような`switch`ステートメントを持つモノレポでのみ重要です。
- ケース内の*ロジックエラー*に対するテストを置き換えるものではありません。*欠落しているケース*のカテゴリのみを捕捉します。
## このパターンを適用する場所
一般的なコードベースで網羅的チェックを最も効果的に適用できる場所:
- **ステートマシン**(UIステート、認証ステート、支払いステート)— すべての新しいステートは明示的に処理される必要があります。
- **ASTビジターまたはイベントハンドラ** — 新しいノードタイプまたはイベントを追加すると、新しいブランチまたは大きなエラーが発生する必要があります。
- **APIレスポンス共用体型** — 新しいレスポンスバリアントは新しいレンダリングパスを表面化する必要があります。
- **CLIサブコマンドディスパッチャ** — 新しいサブコマンドを追加すると、ハンドラが追加されるか、コンパイルに失敗する必要があります。
## クイックリファレンス
```typescript
// インラインバージョン(最も一般的)
function handleStatus(status: Status): string {
switch (status) {
case "idle": return "Waiting";
case "loading": return "Loading...";
case "success": return "Done";
case "error": return "Failed";
default: { const _exhaustive: never = status; return _exhaustive; } }
}
// ヘルパー関数バージョン(大規模なスイッチでよりクリーン)
function assertNever(x: never, msg?: string): never {
throw new Error(msg ?? `Unhandled case: ${JSON.stringify(x)}`);
}
// 識別された共用体を使用
type Tree =
| { type: "leaf"; color: string }
| { type: "branch"; children: Tree[] }
| { type: "fruit"; kind: string };
function describeTree(tree: Tree): string {
switch (tree.type) {
case "leaf": return `Leaf: ${tree.color}`;
case "branch":
return `Branch with ${tree.children.length} children`;
case "fruit": return `Fruit: ${tree.kind}`;
default: return assertNever(tree);
}
} ```
## こちらもどうぞ
- [JavaScriptからLuauへ:完全なRobloxスクリプティングチートシート(2026年版)](/en/blog/javascript-to-luau-roblox-scripting)
- [JavaScriptにおけるconstとイミュータビリティ:値ではなくバインディング](/en/blog/const-in-js)
- [ChatGPT UIで要素の正しいY座標を取得する方法(2026年ガイド)](/en/blog/get_position_of_an_element_in_js)
- [フォールバックにPromise.raceを使うのはやめよう:Promise.anyとAggregateErrorのケース](/en/blog/promise-any-aggregate-error)
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

gRPCとConnectRPC:最新のマイクロサービスとブラウザネイティブなProtobuf
TypeScriptとGoにおけるgRPCとConnectRPCのアーキテクチャを評価し、HTTP/1.1とHTTP/2ストリーミング、Envoyプロキシ不要のブラウザクライアント、p99 RPCレイテンシについて解説します。
Read more
エンタープライズアプリケーションのためのTypeScript高度パターン
branded type、条件付き応答型、テンプレートリテラルルーティング、satisfies演算子など、エンタープライズ向けTypeScriptの高度なパターンを習得しましょう。
Read more
TypeScriptだけでは不十分:Next.jsでZodを使ったエンドツーエンドの型安全性
TypeScriptがランタイムで消失する理由、APIやServer Actionの境界で静的型が機能しない理由、そしてZodがいかに確実なスキーマ検証と型推論を提供するのかを学びましょう。
Read more