パターンマッチ
値の構造に基づいて分岐処理を行う制御構文で、switch 文の強化版
パターンマッチとは
パターンマッチは、値の構造に基づいて分岐処理を行う制御構文である。単純な値の比較だけでなく、型の判別、構造の分解、条件の組み合わせを 1 つの構文で表現できる。Rust の match、Haskell の case が代表例だ。
まず押さえたいのは、専用構文を備える言語と備えない言語があることだ。Rust と Haskell、Scala は言語の中心的な制御構造として持つ。Python は 3.10 で match 文を導入した (PEP 634 の構造的パターンマッチ)。Java は 21 で switch のパターンマッチ (JEP 441) と record パターン (JEP 440) が正式機能になった。一方 TypeScript には専用構文が無く、後述するタグ付きのユニオン型と型の絞り込みで代用する。「TypeScript のパターンマッチ」と紹介される書き方は言語機能ではなく、型検査を利用した書き癖である点を混同しないようにしたい。
Rust の match
Rust の match は、値が列挙のどの変種かという判定と、包んでいた中身の取り出しを 1 つの式で行う。下の Shape::Circle(r) は「Circle であること」を確かめると同時に、中の f64 を r に束縛する。判定と取り出しが分かれないため、確かめた種類と実際に触る中身がずれる書き間違いが起きにくい。
enum Shape {
Circle(f64),
Rectangle(f64, f64),
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
}
// 全パターンを網羅しないとコンパイルエラー
}
腕を 1 つ削ると error[E0004]: non-exhaustive patterns が出てコンパイルが通らない。列挙に変種を足したとき、対応漏れの箇所をコンパイラ側から教えてもらえるのがこの構文の実利である。型の設計としての背景は 代数的データ型 と Rust の Enum を参照。
TypeScript の Discriminated Union
TypeScript には match に相当する構文が無いため、共通のタグとなるフィールドを持つユニオン型を作り、そのタグを if や switch で見る形にする。分岐の中では型が片方に絞られるので、その側にしか無いプロパティへ安全に触れられる。構文の支援ではなく型検査の副産物なので、絞り込みが効く書き方 (タグを別の変数に写さず直接見る、など) を守る必要がある。
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function handle(result: Result<number>) {
if (result.ok) {
console.log(result.value); // TypeScript が value の存在を保証
} else {
console.error(result.error); // TypeScript が error の存在を保証
}
}
網羅性チェック (Exhaustiveness Check)
網羅性チェックは、分岐が値の取り得る全ての形を覆っているかをコンパイラに確かめさせる仕組みである。Rust では言語が保証するが、TypeScript の switch はそれ自体が検査対象ではない。実際に試すと、戻り値型を string と書いた関数で case を 1 つ落としたときには error TS2366: Function lacks ending return statement and return type does not include 'undefined'. が出るが、戻り値型の注釈が無い関数や void の関数では何も報告されない (TypeScript 6.0 系で確認)。戻り値の型注釈を経由した間接的な検出でしかない、ということだ。
そこで default を作り、never 型の引数を取る関数へ渡す。漏れたメンバーは never に代入できないため、確実に型エラーになる。
type Color = 'red' | 'green' | 'blue';
function toHex(color: Color): string {
switch (color) {
case 'red': return '#ff0000';
case 'green': return '#00ff00';
case 'blue': return '#0000ff';
default: return assertNever(color); // 漏れがあるとここで型エラー
}
}
// 到達しないことを型で表明する
function assertNever(x: never): never {
throw new Error(`Unexpected: ${String(x)}`);
}
Color にメンバーを足して case を書き忘れると、Argument of type ... is not assignable to parameter of type 'never'. という形で、漏れているメンバーのリテラルを名指ししたエラーになる。型の定義とそれを使う分岐が別ファイルに離れていても取り違えが起きないので、ユニオン型を分岐するコードでは既定の書き方にしてよい。
Rust の高度なパターン
腕は上から順に照合され、最初に一致した 1 つだけが実行される。範囲、ガード (追加の条件式)、ワイルドカードを組み合わせると、条件分岐を値の構造のまま書き下せる。ガードは値の分解が済んだ後に評価されるため、束縛した変数をそのまま条件式に使える。
match value {
0 => println!("zero"),
1..=9 => println!("single digit"), // 範囲パターン
n if n % 2 == 0 => println!("even"), // ガード条件
_ => println!("other"), // ワイルドカード
}
// 構造体の分解
match point {
Point { x: 0, y } => println!("on y-axis at {y}"),
Point { x, y: 0 } => println!("on x-axis at {x}"),
Point { x, y } => println!("({x}, {y})"),
}
ここに落とし穴が 1 つある。_ (ワイルドカード) を置くと網羅性の条件は常に満たされてしまうので、列挙に変種を足してもコンパイラは何も言わない。追加時に必ず全箇所を見直したい列挙では、_ を使わず変種を書き並べる方が安全だ。TypeScript の default も同じで、そこに既定の処理を書いてしまうと前述の assertNever による検出が働かなくなる。
if-else チェーンとの比較
if-else チェーンとの差は、書き味よりも「間違いがいつ分かるか」に出る。
| 観点 | if-else チェーン | パターンマッチ |
|---|---|---|
| 分岐の漏れ | 実行時に既定の枝へ流れて気づけない | コンパイル時に分かる (Rust は言語が保証・TypeScript は never を使う書き方が必要) |
| 値の取り出し | 判定した後に別途書く | 一致した時点で中身が変数に束縛される |
| 種類を追加したとき | 直す場所を人が探す | 未対応の箇所が示される (ワイルドカードを置いていない場合) |
Lambda ハンドラでの活用
Lambda が受け取るイベントには、種類を示す共通のフィールドが無い。API Gateway のプロキシ統合イベントと SQS のイベントは形そのものが違うだけで、どちらにも「これは API 由来だ」と書かれたタグは入っていない。そこで入口で構造を見て自前のタグを付けた型に正規化し、その後の分岐をパターンマッチに任せる 2 段構えにする。
type Job =
| { kind: 'api'; httpMethod: string; path: string }
| { kind: 'queue'; records: SQSRecord[] }
| { kind: 'schedule' };
// 素のイベントは判別子を持たないので、構造から自前のタグを付ける
function normalize(event: unknown): Job {
if (typeof event !== 'object' || event === null) return { kind: 'schedule' };
if ('Records' in event) return { kind: 'queue', records: (event as SQSEvent).Records };
if ('httpMethod' in event) {
const e = event as APIGatewayProxyEvent;
return { kind: 'api', httpMethod: e.httpMethod, path: e.path };
}
return { kind: 'schedule' };
}
function route(job: Job) {
switch (job.kind) {
case 'api': return handleApi(job);
case 'queue': return handleQueue(job);
case 'schedule': return handleSchedule();
default: return assertNever(job);
}
}
判別に使うフィールドは統合方式で変わる。REST API のプロキシ統合 (ペイロード 1.0) は httpMethod を持つが、HTTP API のペイロード 2.0 では requestContext.http.method に移るため、条件は使っている構成に合わせて選ぶ。正規化を 1 か所に閉じ込めておけば、この差の吸収も 1 か所の修正で済む。
パターンマッチの価値は、記述が短くなることよりも、種類を増やしたときに直すべき場所をコンパイラに数えさせられる点にある。専用構文が無い言語でも、タグ付きのユニオン型と never による表明を組み合わせれば同じ効果は得られる。逆に _ や default へ既定処理を置いた瞬間にその効果は消えるので、どちらを取るかは意識して決めたい。
この記事は役に立ちましたか?