定義
網羅性(exhaustiveness)の強制とは、union のすべてのケースを処理したかどうかを コンパイラに代わりに確認させる ことです。
目標は、union に新しいメンバーを追加したとき、それを処理すべき すべての箇所が赤い線で浮かび上がる ことです。人が記憶を頼りに探し回らなくて済むように。
なぜ必要なのか
union にケースを一つ追加するのは、とてもよくある作業です。注文ステータスに 'refunded' を追加してみましょう。
type OrderStatus = "pending" | "paid" | "shipped" | "refunded"; // ← 追加
function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "支払い待ち";
case "paid": return "支払い完了";
case "shipped": return "配送中";
default: return "不明"; // ← ここに静かに落ちます
}
}
コンパイルは通ります。そして画面には「不明」が出ます。新しいステータスを処理すべき場所がこの関数以外にあと五箇所あるなら、その五箇所も全部静かに誤動作します。
default 分岐が犯人です。 便利だからと入れた catch-all が漏れを隠します。
網羅性を強制すると、この作業はまったく別物になります。union に一行足した瞬間、処理すべき場所がすべてコンパイルエラーとして並びます。直すべきリストをコンパイラが作ってくれるわけです。
動作原理
手段は三つの層に分かれます。依存のないもの から使い、ライブラリは条件が合うときだけ導入します。
第1層 — assertNever(いつでも使えます)
原理は never 型です。TypeScript はすべてのケースを処理し終えると、残った値の型を never に絞り込みます。never ではない値が残っていれば代入が失敗します。
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "支払い待ち";
case "paid": return "支払い完了";
case "shipped": return "配送中";
default: return assertNever(status);
// ^ 'refunded' is not assignable to 'never'
}
}
refunded を処理しなければ、status は 'refunded' に絞り込まれたまま assertNever に渡され、コンパイルが失敗します。catch-all をなくすのではなく、catch-all をコンパイルエラーに変えている のです。
assertNever はリポジトリに既にあれば再利用し、なければ共有の場所ひとつだけに作ります。
第2層 — satisfies Record(結果が静的な値のとき)
ケースごとにやることが「値を選ぶこと」だけなら、switch より lookup オブジェクトのほうが向いています。
const STATUS_LABEL = {
pending: "支払い待ち",
paid: "支払い完了",
shipped: "配送中",
refunded: "返金完了",
} satisfies Record<OrderStatus, string>;
const label = STATUS_LABEL[status];
satisfies は 不足しているキーも余分なキーも両方拒否しつつ、リテラル型はそのまま保ちます。 annotation(: Record<OrderStatus, string>)を使うと値が広がって STATUS_LABEL.pending の型が string になりますが、satisfies は "支払い待ち" を保持します。
ラベルだけでなく、レンダー関数、権限マップ、メッセージマップにもそのまま通用します。
const STATUS_ICON = {
pending: ClockIcon,
paid: CheckIcon,
shipped: TruckIcon,
refunded: RefundIcon,
} satisfies Record<OrderStatus, ComponentType>;
キーに何を使うかは注意してください。 分岐している union そのものを使います。
// リテラル union ならそのまま
satisfies Record<OrderStatus, string>
// タグ付きオブジェクトの union なら indexed access でタグを取り出します
satisfies Record<CheckoutState["status"], string>
第3層 — パターンマッチライブラリ(すでにインストールされているときだけ)
ts-pattern のようなライブラリの .exhaustive() は、ネストした条件まで漏れなく検査してくれます。
match(mutation)
.with({ status: "error", error: { code: "CONFLICT" } }, () => showConflict())
.with({ status: "error" }, () => showGenericError())
.with({ status: "success" }, () => close())
.with({ status: "pending" }, () => showSpinner())
.exhaustive();
ネストしたフィールドで分岐しなければならないとき、第1・2層では表現が汚くなります。そのときだけ価値があります。新しい依存を追加するに値する問題か をまず判断し、すでに入っているなら気軽に使えば大丈夫です。
実務での適用
ラベルマップが欲しいからといってタグ付きオブジェクトを作らない
これはよくある誤判断です。
// ❌ satisfies Record を使うためにリテラル union を包みました
type PaymentBadge = { kind: "unpaid" } | { kind: "paid" } | { kind: "refunded" };
// ✅ satisfies Record はリテラル union にそのまま効きます
type PaymentBadge = "unpaid" | "paid" | "refunded";
const BADGE_LABEL = { unpaid: "未払い", paid: "支払い済み", refunded: "返金済み" }
satisfies Record<PaymentBadge, string>;
タグ付きオブジェクトは メンバーが二つ以上、自分だけのフィールドを持つときだけ です。包むと呼び出し側ごとに .kind を剥がすコストが増えるだけです。
層を選ぶ順番
| 状況 | 層 |
|---|---|
| ケースごとにロジックが違い、early return が混ざる | 第1層(assertNever) |
| ケースごとに静的な値やコンポーネントを選ぶだけ | 第2層(satisfies Record) |
| ネストしたフィールドの組み合わせで分岐する必要がある + ライブラリが既にある | 第3層(パターンマッチ) |
新しいケースを追加するときの流れ
網羅性がきちんと効いていると、作業の順番はこうなります。
- union にメンバーを追加します。
- 型チェックを走らせます。
- コンパイラが直すべきリストを出力してくれます。
- リストを上から処理します。
- リストが空になれば終わりです。
これができないと、3 番が「コードベースを grep して自分で探す」に変わります。
トレードオフ
書く時点で少し手間が増えます。 assertNever ヘルパーを作る必要がありますし、lookup オブジェクトは switch より馴染みが薄いかもしれません。
得られるのは変更時点の安全です。 そしてこのコスト/利益の比率は、union が長く生きるほど良くなります。一度使って捨てる union ならわざわざやらなくてよく、ドメイン状態のように増え続ける union なら必ずやるべきです。
もう一つ微妙なコストがあります。網羅性を強制すると、union への追加が「大きな変更」になります。 五箇所が赤くなるからです。これは実は正確なシグナルです — もともと五箇所を直すべき変更だったのですから。ただ、チームがそのシグナルを「面倒」と受け取ると、default 分岐を戻そうという圧力が生まれます。
使ってはいけない場合
- 消費側が拡張する開いた集合。 プラグインのキーやアプリ固有のイベント名のように消費側が増やす集合は、そもそも閉じた union ではありません。網羅性ではなく typed registry や module augmentation の領域です。
- 本当にデフォルト動作が定義されている場合。「知られていない状態はグレーのバッジ」が 要件として決まっているなら catch-all が正解です。ただしその場合も
assertNeverの代わりに、そのポリシーを明示的に書き残してください。習慣的なdefaultと区別できるように。 - 一つのファイルの中だけで使う短い union。 使用箇所がすぐ隣にあるなら、コンパイラの助けがなくても目で見えます。
よくあるミス
default: return '不明'を入れてしまう。 便利だからと入れた一行が、将来のすべての漏れを飲み込みます。assertNeverを作ったのにdefaultに入れない。 ヘルパーがあるだけで使わなければ何の効果もありません。satisfiesの代わりに annotation を使う。const X: Record<K, string> = {...}は不足は捕まえますが、リテラル型を広げてしまいます。Recordのキーを間違って選ぶ。 タグ付きオブジェクトの union にRecord<State, ...>を使ってはいけません。Record<State['status'], ...>でタグを取り出す必要があります。- 不可能な組み合わせまで処理しようとする。 そもそも union の設計が悪くて不可能な状態が表現できてしまうなら、網羅性を強制する前に union のほうを先に直します。
- ラベルマップを使いたくてリテラル union をタグ付きオブジェクトで包む。
satisfies Recordはリテラル union にそのまま効きます。
関連概念
- state-modeling-ladder — 網羅性を強制する union をそもそも作るべきかどうかから判断する
- false-type-contracts —
satisfiesをランタイム検証と勘違いしない - type-level-testing — union を広げたときに実際にテストが失敗するか確認する