あなたはshape型をリファクタリングし、新しいバリアントを追加した。TypeScriptはグリーンのままだった。CIは通過した。デプロイも完了した。そしてユーザーがundefinedを返す実行時の分岐に到達し、アプリがプロダクションで例外を投げた。

犯人はほぼ間違いなくswitch文だ。TypeScriptのswitchには網羅性の保証がない。ユニオンに新しいメンバーを追加すると、そのユニオンを対象とするすべてのswitchが黙って不完全になる。コンパイラは止めてくれない。警告すら出さない。ここが人々を惑わせる部分だ:TypeScriptは可能な値の全体集合を知っていても、switch文は網羅性チェックに参加しない。

網羅性チェックとは何か

網羅性とは、コンパイラがすべての可能な値が処理されたことを証明できる状態を意味する。RustやOCamlのような言語はこれをコンパイル時に強制する。TypeScriptは——switch文については——そうではない。

判別ユニオンを考えてみよう:

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;
    // triangle is missing — and TypeScript is fine with it
  }
}

これはコンパイルされる。shape.kind === "triangle"のとき、undefinedを返す。関数シグネチャはnumberを約束しているのに、実装はそれを履行しない。TypeScriptは欠落したケースを指摘しない。switchブロックは完全性についてチェックされないからだ。

オブジェクトルックアップパターン

最もシンプルな置き換えは、プレーンなオブジェクトマップだ。すべてのキーがハンドラ関数に対応する。判別子でオブジェクトにインデックスアクセスすると、コンパイラはキーの存在をチェックする。

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

これは改善だ。Shape"polygon"を追加すると、TypeScriptはareaHandlersにキーが欠けていると文句を言う。エラーは正しい場所を指し、コンパイル時に起こる。

だが、キャストは醜い。もっと良くできる。

キャストなしの型安全なハンドラ

コツは、各バリアントを絞り込まれた型を直接受け取る関数にマップすることだ。マップ型を使い、キーと特定のshapeバリアントの関係を保持する。

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

これで各ハンドラは正しく絞り込まれた引数を受け取る。関数内部に手動のキャストはない。最後の行のas neverは小さな瑕疵だが、局所的で安全だ。本当の価値は、areaHandlersShapeKindのすべてのキーを含まなければならないことだ。1つ削除するか、Shapeに新しいバリアントを追加すれば、ビルドが壊れる。

neverアサートによる網羅性チェック

一部のチームはswitchを維持しつつ、コンパイル時の網羅性ガードを追加することを好む。これは実用的な中間地点だ。neverを受け取るdefaultブランチを追加し、コンパイラにすべてのケースが処理されたことを検証させる。

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

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 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

新しいshape kindを追加してケースを忘れた場合、defaultブランチはneverではない値を受け取る。具体的な型をneverに割り当てられないため、TypeScriptはエラーになる。エラーはswitchを指す。まさにそこに、エラーを出してほしい。

このパターンはSentryや他の大規模なTypeScriptコードベースで広く使われている。コードの構造を変える必要はない。コンパイラを味方にするだけだ。

サードパーティライブラリによるパターンマッチング

Rustのmatchに近いものが欲しければ、ts-patternのようなライブラリが網羅性チェックを組み込んだパターンマッチングを提供する。

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

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

.exhaustive()の呼び出しが鍵だ。バリアントが欠けていれば、TypeScriptはエラーを報告する。このライブラリはネストしたパターン、ガード、ワイルドカードもサポートしており、複雑なネストしたswitchブロックを置き換えられる。

これは強力だが、依存関係を追加する。単一の判別ユニオンにとって、ts-patternは大げさすぎる。パターンマッチングが多用されるコードベースでは、バンドルサイズの価値がある。

Switch文がまだ優れる場面

switchが常に悪いわけではない。ケースがフォールスルーする必要がある場合、あるいはロジックが早期リターンや副作用を伴う命令型である場合、switchはルックアップテーブルや.with()呼び出しのチェーンより読みやすいことがある。

問題は構文ではない。網羅性チェックの欠如だ。switchを使うなら、assertNeverガードを追加しよう。2行のコードで、安全上の穴を塞げる。

すべてを書き換えずに導入する方法

今日、すべてのswitchをリファクタリングする必要はない。実践的な導入順序はこうだ:

  1. 共有ユーティリティにassertNeverを追加する。新しいswitchブロックで使う。
  2. 既存のユニオン上のswitchに触れるとき、その時点でガードを追加する。
  3. 判別ユニオンを扱う新しいコードでは、ロジックが純粋で各ケースが独立しているなら、オブジェクトマップパターンを優先する。
  4. ネストしたswitchや複雑なマッチングロジックを何度も書いているなら、ts-patternを検討する。

目標はswitch文を排除することではない。コンパイラに、ユーザーより先にバグを捕まえさせることだ。

実際に確かめたければ、AreaMapの例からハンドラを1つ削除して、tscが文句を言うのを見てみよう。その赤い波線が、静かな金曜日のデプロイと週末のPagerDutyページとの違いだ。