match という構文はいまや多くの言語にあります。ただし同じ名前でも能力はまちまちで、単なる switch の言い換えに留まるものから、分岐の漏れをコンパイラが検出するものまで幅があります。

違いを測る物差しとして、網羅性検査の有無がもっとも重要です。すべてのケースを書いたかどうかを機械が確かめてくれるか、という点です。

4 段階で捉える

レベル できること 代表
1. 値の照合 定数との一致で分岐する PHP の match
2. 型の絞り込み 型で分岐し、その枝で型が確定する TypeScript
3. 構造の分解 入れ子のデータを分解して束縛する Python
4. 直和型と網羅性 3 に加え、漏れをコンパイラが検出する Rust

上のレベルは下を含みます。そして 3 と 4 の間に、実務上いちばん大きな段差があります。

レベル1: 値の照合

PHP の match は式であり、厳密比較で分岐します。

$label = match ($status) {
    UserStatus::Active => '利用中',
    UserStatus::Suspended => '停止中',
};

switch との違いは 3 点です。式なので値を返す、break が要らない、比較が === 相当になる。地味ですが、switch の代表的なバグ(break 忘れと緩い比較)が構造的に消えます。

enum のケースを書き漏らすと UnhandledMatchError になりますが、これは実行時です。その分岐を通るまで気づきません。

レベル2: 型の絞り込み

TypeScript には match 構文はありませんが、判別可能なユニオン型で同等のことができます。

type Command =
  | { kind: "create"; name: string }
  | { kind: "move"; x: number; y: number }
  | { kind: "quit" };

function run(command: Command): string {
  switch (command.kind) {
    case "create":
      return command.name;        // ここでは create 型に確定している
    case "move":
      return `${command.x},${command.y}`;
    case "quit":
      return "bye";
  }
}

各分岐の中で型が自動的に絞り込まれます。さらに never を使うと、網羅漏れをコンパイル時に検出できます。

function assertNever(value: never): never {
  throw new Error(`unhandled: ${value}`);
}

// default 節で assertNever(command) を呼ぶと、
// ケースを足し忘れたときにコンパイルエラーになる

言語機能として自動ではなく、この定型句を自分で書く必要があるのが特徴です。仕組みは用意されているが、使うかどうかは書き手に委ねられています。

レベル3: 構造の分解

Python の match は値の一致ではなく、構造の照合を行います。

def handle(message: object) -> str:
    match message:
        case {"type": "user", "id": int(user_id)}:
            return f"user:{user_id}"
        case ["move", int(x), int(y)]:
            return f"move:{x},{y}"
        case None:
            return "empty"
        case _:
            return "unknown"

辞書・リスト・クラスを分解しながら、同時に型を確認して変数へ束縛できます。ガード条件も書けます。

match point:
    case Point(x, y) if x > 0 and y > 0:
        return "first"

表現力は高い一方で、Python は標準では網羅性を保証しません。case _ を書き忘れ、どのパターンにも当たらなければ match 文は何もせずに次へ進みます。エラーにすらなりません。

もう 1 つの落とし穴が、捕捉パターンと定数パターンの取り違えです。

match status:
    case ACTIVE:      # 定数比較のつもりが、すべてを ACTIVE に束縛してしまう
        ...

裸の名前は「その値と比較する」ではなく「この名前に束縛する」と解釈されます。結果として最初の枝が常に成立します。定数と比較したいなら Status.ACTIVE のようにドット付きで書きます。

レベル4: 直和型と網羅性

Rust では、直和型と match が組みになっています。

enum Command {
    Create { name: String },
    Move(i32, i32),
    Quit,
}

fn run(command: Command) -> String {
    match command {
        Command::Create { name } => name,
        Command::Move(x, y) => format!("{x},{y}"),
        Command::Quit => String::from("bye"),
    }
}

ここで Command::Quit の枝を消すとコンパイルが通りません。さらに後から enum にケースを追加すると、そのケースを処理していない match がすべてコンパイルエラーになります

これが実務でいちばん効きます。状態を 1 つ増やしたとき、対応が必要な箇所を人間が探すのではなく、コンパイラが全部列挙してくれる。パターンマッチの価値は構文の簡潔さではなく、この網羅性検査にあります。

何を確認すべきか

新しい言語のパターンマッチを見るときは、構文より先に次を確かめると性質がつかめます。

  • 網羅性を検査するか。するのはコンパイル時か実行時か
  • ガード条件を書けるか
  • 入れ子の構造を分解できるか
  • 分解しながら変数へ束縛できるか
  • 分岐の中で型が絞り込まれるか
  • 到達しないパターンを警告するか

まとめ

  • パターンマッチは値の照合・型の絞り込み・構造の分解・網羅性検査の 4 段階で捉えると差が見えます。
  • PHP の match は式であり厳密比較を行うため、switch の典型的なバグは消えます。ただし漏れの検出は実行時です。
  • TypeScript は判別可能なユニオン型と never で網羅性を検査できますが、定型句を自分で書く必要があります。
  • Python の match は構造分解が強力な反面、網羅性は保証されません。裸の名前は比較ではなく束縛になる点が最大の罠です。
  • Rust は網羅性がコンパイル時に保証されます。enum にケースを足したとき、対応漏れを機械が全部指摘してくれることがこの機能の本体です。