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 + satisfies | ts-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 never や assertNever を使い、処理漏れをコンパイルエラーにしてください。
落とし穴2: as による型アサーションで型情報を壊す
// NG: asで型を上書きするとexhaustive checkが無効化される
const status = response.status as "pending" | "approved";
as で型を狭めると、実際には "rejected" が来る可能性があるにもかかわらず、コンパイラはチェックできません。
回避策: 外部データに対しては as ではなくランタイムバリデーション(zodやvalibotなどのスキーマバリデーションライブラリ)を使って型を確定させてください。
落とし穴3: strictNullChecksが無効
tsconfig.json で strictNullChecks が false の場合、never型への絞り込みが正しく機能しないことがあります。
回避策: tsconfig.json で strict: 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
}
}
| オプション | 効果 |
|---|---|
strict | strictNullChecksを含む厳格な型チェックを有効化。never型への絞り込みが正しく動作する前提条件 |
noFallthroughCasesInSwitch | break/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.json で strict: true を有効にし、Discriminated Unionとexhaustive checkを組み合わせることで、コード変更時のバグ混入リスクを大幅に削減できます。
