TypeScriptでUnion型を扱うswitch文に新しいケースを追加し忘れ、本番環境で予期しない動作が起きた経験はないでしょうか。exhaustive check(網羅性チェック)は、Union型のすべての値が分岐処理で漏れなくカバーされていることをコンパイル時に保証する仕組みです。

exhaustive check(網羅性チェック)の基本原理

exhaustive checkは、分岐処理がUnion型の全メンバーを処理しているかどうかをTypeScriptのコンパイラに検証させるテクニックです。仕組みの核となるのは never型 — TypeScriptの型システムで「到達不可能」を表す特殊な型です。

switch文やif-else文でUnion型のすべてのケースを処理すると、default節(またはelse節)に到達する値は存在しなくなります。このとき、その変数の型はnever型に絞り込まれます(型の絞り込み=ナローイング)。逆にケースの処理漏れがあれば、default節に到達する可能性が残り、変数の型はneverにならず、コンパイルエラーとして検出できます。

type Color = "red" | "green" | "blue";

function getColorCode(color: Color): string {
  switch (color) {
    case "red":
      return "#ff0000";
    case "green":
      return "#00ff00";
    case "blue":
      return "#0000ff";
    default: {
      // すべてのケースを処理済みなので、colorはnever型
      const _: never = color;
      throw new Error(`未対応の色: ${_}`);
    }
  }
}

Color型に "yellow" を追加した場合、"yellow" のcaseが存在しないためdefault節のcolorは "yellow" 型のままとなり、never型への代入でコンパイルエラーが発生します。これがexhaustive checkの基本動作です。

never型がexhaustive checkを実現する仕組み

never型は「値が存在しない」ことを示すボトム型です。TypeScriptの型システムにおいて、任意の型をneverに代入することはできません。この制約がexhaustive checkの根幹です。

type Status = "pending" | "approved" | "rejected";

function handleStatus(status: Status): void {
  if (status === "pending") {
    // statusは "pending"
    return;
  }
  if (status === "approved") {
    // statusは "approved"
    return;
  }
  // ここでstatusは "rejected" — never型ではない
  // const _: never = status; // コンパイルエラー!
}

上記のコードでは "rejected" の処理が抜けているため、最後のstatusは "rejected" 型であり、never型への代入でコンパイルエラーになります。"rejected" の処理を追加すれば、statusはnever型に推論され、エラーは解消します。

ナローイングとexhaustive checkの関係

TypeScriptのナローイング(型の絞り込み)機能は、制御フロー分析によって変数の型を段階的に狭めていきます。exhaustive checkは、このナローイングがUnion型の全メンバーを消費し尽くしたことを「never型への到達」で確認する手法です。

ナローイングの手段用途exhaustive checkとの相性
typeofプリミティブ型の判別限定的(文字列リテラルUnionには不向き)
instanceofクラスインスタンスの判別クラスベースのUnionで有効
in演算子プロパティ存在チェックオブジェクト型Unionで利用可能
判別プロパティ(Discriminated Union)共通プロパティでの型分岐最も相性が良い
等価比較(=== / !==)リテラル値での判別文字列リテラルUnionで有効

exhaustive checkの実装パターン3選

実務で使われる主要な3つのパターンを、コード例・メリット・デメリットとともに整理します。

パターン1: assertNever関数で例外を投げる

never型の引数を受け取る関数を定義し、未処理のケースで呼び出す方法です。コンパイル時チェックと実行時の安全性を両立できます。

function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${value}`);
}

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape);
  }
}

Shape{ kind: "pentagon"; side: number } を追加すると、default節の shape{ kind: "pentagon"; side: number } 型のままとなり、assertNever の引数型 never と一致せずコンパイルエラーが出ます。

メリット: 実行時にも不正な値が渡された場合にエラーを投げるため、外部入力に対する防御になります。再利用可能なユーティリティ関数として1箇所に定義すれば、プロジェクト全体で統一的に使えます。

デメリット: ユーティリティ関数の定義が必要です。

パターン2: never型変数への代入(型チェックのみ)

最もシンプルなパターンです。default節でnever型の変数に代入するだけで、コンパイル時の網羅性を検証できます。

type Direction = "up" | "down" | "left" | "right";

function move(direction: Direction): void {
  switch (direction) {
    case "up":
      console.log("上に移動");
      break;
    case "down":
      console.log("下に移動");
      break;
    case "left":
      console.log("左に移動");
      break;
    case "right":
      console.log("右に移動");
      break;
    default: {
      const _exhaustiveCheck: never = direction;
      break;
    }
  }
}

メリット: 外部ライブラリやユーティリティ関数が不要です。TypeScriptの型システムだけで完結します。

デメリット: 実行時の安全ネットがありません。JavaScriptにトランスパイルされた後、予期しない値がdefaultに到達しても何も起きません。未使用変数の警告が出る場合があります(ESLintのno-unused-varsやTypeScriptのnoUnusedLocals設定による)。

パターン3: satisfies演算子でインラインチェック

TypeScript 4.9で導入された satisfies 演算子を使う方法です。変数宣言なしで1行で網羅性を検証できます。

type Fruit = "apple" | "banana" | "orange";

function getFruitEmoji(fruit: Fruit): string {
  switch (fruit) {
    case "apple":
      return "🍎";
    case "banana":
      return "🍌";
    case "orange":
      return "🍊";
    default:
      throw new Error(`未対応のフルーツ: ${fruit satisfies never}`);
  }
}

satisfies never は、式の型がneverを満たすかをチェックします。網羅性が不足していれば、fruit の型がneverではないためコンパイルエラーになります。

メリット: 1行で型チェックと実行時エラーの両方を実現できます。未使用変数が発生しません。TypeScript 4.9以降の標準機能のみで完結します。

デメリット: TypeScript 4.9未満では使えません。チーム内でsatisfies演算子の理解度にばらつきがある場合、可読性が下がる可能性があります。

3パターンの比較

比較項目assertNever関数never型変数代入satisfies演算子
コンパイル時チェック可能可能可能
実行時エラー発生ありなしthrow文と併用で可能
必要なTypeScriptバージョン制限なし制限なし4.9以上
コード量関数定義+呼び出し2行(変数宣言+代入)1行
未使用変数の警告なしあり(設定次第)なし
再利用性高(共通関数として利用)低(毎回変数を宣言)中(パターンとして統一)

Discriminated Union(判別可能なUnion型)との組み合わせ

exhaustive checkが最も力を発揮するのは、Discriminated Union(判別可能なUnion型)と組み合わせた場合です。Discriminated Unionとは、各メンバーが共通の判別プロパティ(discriminant)を持つUnion型のことです。

// APIレスポンスを判別可能なUnion型で定義
type ApiResponse =
  | { status: "success"; data: unknown }
  | { status: "error"; code: number; message: string }
  | { status: "loading" }
  | { status: "idle" };

function renderResponse(response: ApiResponse): string {
  switch (response.status) {
    case "success":
      return `データ取得成功: ${JSON.stringify(response.data)}`;
    case "error":
      return `エラー(${response.code}): ${response.message}`;
    case "loading":
      return "読み込み中...";
    case "idle":
      return "待機中";
    default:
      throw new Error(
        `未対応のステータス: ${response satisfies never}`
      );
  }
}

APIのレスポンス仕様が変更されて { status: "timeout" } が追加された場合、switch文に "timeout" のcaseがなければ即座にコンパイルエラーが発生します。API仕様の変更に対する型レベルの追従漏れを防止できます。

入れ子のDiscriminated Unionでの活用

複雑な状態遷移では、Discriminated Unionが入れ子になることがあります。外側と内側の両方でexhaustive checkを適用することで、状態の組み合わせ漏れを防げます。

type PaymentMethod =
  | { type: "credit"; brand: "visa" | "mastercard" | "amex" }
  | { type: "bank"; bankCode: string }
  | { type: "wallet"; provider: "paypay" | "merpay" };

function getPaymentLabel(method: PaymentMethod): string {
  switch (method.type) {
    case "credit": {
      switch (method.brand) {
        case "visa":
          return "Visa";
        case "mastercard":
          return "Mastercard";
        case "amex":
          return "American Express";
        default:
          throw new Error(
            `未対応ブランド: ${method.brand satisfies never}`
          );
      }
    }
    case "bank":
      return `銀行振込(${method.bankCode})`;
    case "wallet": {
      switch (method.provider) {
        case "paypay":
          return "PayPay";
        case "merpay":
          return "メルペイ";
        default:
          throw new Error(
            `未対応プロバイダ: ${method.provider satisfies never}`
          );
      }
    }
    default:
      throw new Error(`未対応の決済手段: ${method satisfies never}`);
  }
}

ts-patternライブラリで宣言的にパターンマッチする

TypeScriptにはRustやHaskellのようなネイティブのパターンマッチ構文がありません。ts-patternは、TypeScriptに宣言的なパターンマッチを持ち込むライブラリで、.exhaustive() メソッドによる網羅性チェックが組み込まれています。

インストール

npm install ts-pattern

基本的な使い方

import { match, P } from "ts-pattern";

type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keydown"; key: string }
  | { type: "scroll"; delta: number };

function handleEvent(event: Event): string {
  return match(event)
    .with({ type: "click" }, (e) => `クリック: (${e.x}, ${e.y})`)
    .with({ type: "keydown" }, (e) => `キー入力: ${e.key}`)
    .with({ type: "scroll" }, (e) => `スクロール: ${e.delta}px`)
    .exhaustive();
}

.exhaustive() を呼び出すと、match に渡されたUnion型の全ケースが .with() で処理されているかをコンパイル時に検証します。ケースの追加漏れがあればコンパイルエラーになります。

switch文との比較

// switch文 + satisfies(標準TypeScript)
function describeShape(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return `半径${shape.radius}の円`;
    case "rectangle":
      return `${shape.width}×${shape.height}の矩形`;
    case "triangle":
      return `底辺${shape.base}、高さ${shape.height}の三角形`;
    default:
      throw new Error(`未対応: ${shape satisfies never}`);
  }
}

// ts-pattern(ライブラリ使用)
function describeShapeWithPattern(shape: Shape): string {
  return match(shape)
    .with({ kind: "circle" }, (s) => `半径${s.radius}の円`)
    .with({ kind: "rectangle" }, (s) => `${s.width}×${s.height}の矩形`)
    .with({ kind: "triangle" }, (s) => `底辺${s.base}、高さ${s.height}の三角形`)
    .exhaustive();
}
比較項目switch + satisfiests-pattern
網羅性チェック手動でdefault節に記述.exhaustive()で自動保証
ネストしたマッチswitch入れ子で冗長.with()のネストで簡潔
ガード条件の付与case内でif文が必要.with(P.when(...)) で宣言的
外部依存なしts-patternパッケージが必要
バンドルサイズ0KB約2KB(gzip後)
型推論の精度TypeScript標準高精度(各.with内で型が絞り込まれる)

ts-patternは、分岐が多い・条件が複雑・入れ子が深いケースで特に威力を発揮します。一方、シンプルなUnion型の分岐であれば標準のswitch文+satisfiesで十分です。

ESLint switch-exhaustiveness-checkの導入

@typescript-eslint/switch-exhaustiveness-check は、switch文がUnion型の全ケースを網羅しているかをESLintルールとして検査するものです。コード内にnever型チェックを書かなくても、リンターレベルで網羅性を保証できます。

設定方法

// eslint.config.js(Flat Config形式)
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommended,
  {
    rules: {
      "@typescript-eslint/switch-exhaustiveness-check": [
        "error",
        {
          allowDefaultCaseForExhaustiveSwitch: false,
          requireDefaultForNonUnion: true,
        },
      ],
    },
  }
);

オプションの意味

  • allowDefaultCaseForExhaustiveSwitch: false に設定すると、すべてのケースが網羅されたswitch文にdefault節を書いた場合にもエラーを報告します。不要なdefault節の除去を促すことで、将来のUnionメンバー追加時に確実にコンパイルエラーを発生させます。
  • requireDefaultForNonUnion: true に設定すると、Union型以外のswitch文(string型やnumber型)にはdefault節を要求します。

ESLintルール vs コード内exhaustive check

観点ESLintルールコード内チェック(never / satisfies)
チェックタイミングリント実行時コンパイル時
実行時の安全性なしthrow文と組み合わせ可能
設定の手間eslint.config.jsに1行追加各switch文にdefault節を記述
default節の扱い不要なdefault節を検出可能default節が必須
CI/CD統合ESLintステップで検出tscステップで検出

実務では両方を併用するのが堅実です。ESLintルールで網羅性のベースラインを確保しつつ、外部データを処理するswitch文にはコード内のexhaustive checkで実行時エラーも投げるようにします。

実務で役立つexhaustive checkの適用場面

Redux / useReducerのアクション処理

状態管理のreducer関数は、定義されたアクション型をすべて処理する必要があります。アクション追加時にreducerの更新漏れを防止できます。

type CounterAction =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset"; value: number };

type CounterState = { count: number };

function counterReducer(
  state: CounterState,
  action: CounterAction
): CounterState {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: action.value };
    default:
      throw new Error(
        `未対応のアクション: ${action satisfies never}`
      );
  }
}

HTTPメソッドのルーティング

バックエンドのルーターやAPIクライアントで、対応するHTTPメソッドの処理漏れを検出します。

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

function buildFetchOptions(
  method: HttpMethod,
  body?: unknown
): RequestInit {
  switch (method) {
    case "GET":
      return { method: "GET" };
    case "POST":
      return { method: "POST", body: JSON.stringify(body) };
    case "PUT":
      return { method: "PUT", body: JSON.stringify(body) };
    case "DELETE":
      return { method: "DELETE" };
    case "PATCH":
      return { method: "PATCH", body: JSON.stringify(body) };
    default:
      throw new Error(
        `未対応のHTTPメソッド: ${method satisfies never}`
      );
  }
}

フォームバリデーションのエラー種別

バリデーションエラーの種別を網羅的に処理し、ユーザーへのエラーメッセージ表示漏れを防ぎます。

type ValidationError =
  | { kind: "required"; field: string }
  | { kind: "minLength"; field: string; min: number }
  | { kind: "maxLength"; field: string; max: number }
  | { kind: "pattern"; field: string; regex: string };

function formatError(error: ValidationError): string {
  switch (error.kind) {
    case "required":
      return `${error.field}は必須です`;
    case "minLength":
      return `${error.field}${error.min}文字以上で入力してください`;
    case "maxLength":
      return `${error.field}${error.max}文字以下で入力してください`;
    case "pattern":
      return `${error.field}の形式が正しくありません`;
    default:
      throw new Error(
        `未対応のバリデーション種別: ${error satisfies never}`
      );
  }
}

よくある落とし穴と回避策

落とし穴1: default節にreturnだけ書いてしまう

// NG: 網羅性チェックになっていない
function toLabel(status: Status): string {
  switch (status) {
    case "pending":
      return "保留中";
    case "approved":
      return "承認済み";
    default:
      return "不明"; // "rejected" の処理漏れに気付けない
  }
}

default節で return "不明" のように安易にフォールバック値を返すと、Union型に新しいメンバーが追加されてもコンパイルエラーが発生せず、サイレントに不正な動作を引き起こします。

回避策: default節では必ず satisfies neverassertNever を使い、処理漏れをコンパイルエラーにしてください。

落とし穴2: as による型アサーションで型情報を壊す

// NG: asで型を上書きするとexhaustive checkが無効化される
const status = response.status as "pending" | "approved";

as で型を狭めると、実際には "rejected" が来る可能性があるにもかかわらず、コンパイラはチェックできません。

回避策: 外部データに対しては as ではなくランタイムバリデーション(zodやvalibotなどのスキーマバリデーションライブラリ)を使って型を確定させてください。

落とし穴3: strictNullChecksが無効

tsconfig.jsonstrictNullChecksfalse の場合、never型への絞り込みが正しく機能しないことがあります。

回避策: tsconfig.jsonstrict: true または少なくとも strictNullChecks: true を有効にしてください。

{
  "compilerOptions": {
    "strict": true
  }
}

落とし穴4: 列挙型(enum)の数値メンバーが意図せず通過する

TypeScriptの数値enumは、実行時に数値が直接渡されるとswitch文のcaseに一致せず、default節に到達することがあります。

enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
  Right, // 3
}

function move(d: Direction): void {
  switch (d) {
    case Direction.Up: break;
    case Direction.Down: break;
    case Direction.Left: break;
    case Direction.Right: break;
    default:
      // 数値enum はruntime で任意の数値が渡されうる
      throw new Error(`未対応: ${d satisfies never}`);
  }
}

// 型チェックをすり抜ける例
move(99 as Direction); // コンパイルエラーにならない

回避策: 数値enumの代わりに文字列リテラルUnionまたは const enum を検討してください。外部入力を扱う場合はランタイムバリデーションが必須です。

tsconfig.jsonでexhaustive checkを最大限に活かす設定

exhaustive checkの効果を十分に引き出すためには、TypeScriptのコンパイラオプションを適切に設定する必要があります。

{
  "compilerOptions": {
    "strict": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true
  }
}
オプション効果
strictstrictNullChecksを含む厳格な型チェックを有効化。never型への絞り込みが正しく動作する前提条件
noFallthroughCasesInSwitchbreak/returnのないcase節をエラーにする。意図しないフォールスルーを防止
noImplicitReturnsすべてのコードパスで明示的なreturnを要求。switch文でケース漏れによるundefined返却を検出

まとめ

exhaustive check(網羅性チェック)は、Union型の分岐処理で「ケースの追加忘れ」をコンパイル時に検出するためのTypeScriptの重要な型安全テクニックです。

主要な実装手法は3つあります。

  • assertNever関数: コンパイル時チェック+実行時エラーの両立。プロジェクト全体で統一的に使える
  • never型変数代入: 追加の依存なしで最もシンプル。実行時安全性は別途担保が必要
  • satisfies演算子: TypeScript 4.9以降で利用可能。1行で型チェックと実行時エラーを兼ねる現在の推奨パターン

さらに、ts-patternライブラリの .exhaustive() メソッドによる宣言的なパターンマッチや、ESLintの @typescript-eslint/switch-exhaustiveness-check ルールによるリンターレベルの網羅性保証も組み合わせることで、より堅牢な型安全を実現できます。

tsconfig.jsonstrict: true を有効にし、Discriminated Unionとexhaustive checkを組み合わせることで、コード変更時のバグ混入リスクを大幅に削減できます。