•12 min read

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

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

never type

Click to reveal
Type Safety
TypeScriptのボトム型 — 取りうる値がない型。決して正常にリターンしない(常に例外をスローするか無限ループする)関数の戻り値の型として使用されます。

never type

Type Safety

exhaustive check

Click to reveal
Type Safety
`switch`と`never`型のデフォルトケースを使用するパターン。新しい共用体メンバーを追加しても、スイッチで処理し忘れると、TypeScriptはコンパイル時にエラーを出します。

exhaustive check

Type Safety

type guard

Click to reveal
Type Safety
共用体型をそのメンバーの1つに絞り込むランタイム述語関数またはチェック。`in`演算子、`typeof`、または`is`キーワードを持つカスタム述語とともに使用されます。

type guard

Type Safety

discriminated union

Click to reveal
Type Safety
すべてのメンバーが共通のリテラル型プロパティ(「タグ」)を共有する共用体。TypeScriptは明示的なガードなしに、そのプロパティに基づいて型を絞り込みます。

discriminated union

TypeScriptの網羅的チェックがいかにバグのカテゴリ全体を排除するか

TypeScriptはコンパイル時に多くのバグを捕捉します。しかし、TypeScriptを単なる便利なアシスタントから容赦ない監査役に変えるパターンが1つあります。それがnever型を使った網羅的チェックです。その違いは、switchでケースが欠落していても、「これは未定義かもしれない」という曖昧な警告ではなく、忘れた正確な行でハードエラーが発生することです。

Audio Briefing
0:00 / 0:00

解決する問題

進化するコードベースでは、enumのようなswitchステートメントはすべて時限爆弾です。3つのケースに対するハンドラを記述します。3ヶ月後、誰かが4番目のバリアントを追加します。コンパイルは通ります。テストもパスします。本番環境では新しいケースが黙って無視されます。顧客が報告するまでエラーは発生しません。

網羅的チェックがその解決策です。これにより、コンパイラは単なるスペルチェッカーではなく、あなたのレビュー担当者になります。

Advertisement

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値があっても黙ってコンパイルされます。ランタイムエラーは、そのパスが実際に本番環境でヒットしたときにのみ発生します。網羅的チェックは、誰かがデプロイする前に、コンパイル時に発生します。

AspectNaive default throwExhaustive 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)
Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement