TSのパターンマッチ
モニクル Advent Calendar 2025の5日目の記事です.
はじめに
みなさん,TSのパターンマッチ使っていますか?
「TSのパターンマッチ?」
「いやいや,JSにパターンマッチはないから」
「TC39のプロポーザル[1]のこと?」
確かにTSはJSのスーパーセットに当たるため,一部namespaceやenumといったTSの独自拡張構文は存在するものの,基本的にJSの世界に存在しない構文を使用することはできません.
しかし,TSにはTSの世界にしか存在しない「型」があります.今回はTSのユニオン型,オーバーロード関数を利用して,型推論による型レベルのパターンマッチを活用しようという話になります.
オーバーロード関数
TSにはオーバーロード関数と呼ばれる仕組みがあります.
オーバーロード関数の例
function foo(x: number | string): number | string {
if (typeof x === "number") {
return x.toString();
} else {
return Number(x);
}
}
上記の関数は次のように推論されます.
const a = foo(1); // a: number | string
const b = foo("2"); // b: number | string
しかし,実装者視点だとid(1)は"1"が返るのでstringだし,id("2")は2が返るのでnumberと推論して欲しいと考えてしまいます.
そこで,次のように関数の型定義(関数シグネチャ)を複数定義することで,呼び出し時の引数の型に応じて戻り値の型を変えることができます.
function foo(n: number): string;
function foo(s: string): number;
function foo(x: number | string): number | string {
// ...
}
この関数は次のように推論されます.これをオーバーロード関数と呼びます.
const a = foo(1); // a: string
const b = foo("2"); // b: number
別案: ジェネリクスを利用する
これと同じ型定義をジェネリクスを利用して次のように書くこともできますが,オーバーロード関数の方が読みやすいのではないでしょうか?
function foo<T extends number | string>(x: T): T extends number ? string : number {
// ...
}
ドメインを正確に表現したい
例えば,次のようJSの関数があります.
1の場合は"one"を返し,2の場合は"two",それ以外の場合は"other"を返す関数です.
function foo(n) {
switch (n) {
case 1:
return "one";
case 2:
return "two";
default:
return "other";
}
}
この関数にどのような型を付与するのが良いか考えてみましょう.
numberを受け取り,stringを返す
一番シンプルな型情報としては,次のようになるのではないでしょうか?
function foo(n: number): string {
switch (n) {
case 1:
return "one";
case 2:
return "two";
default:
return "other";
}
}
この場合,次のように推論されます.
const a = foo(1); // a: string
const b = foo(2); // b: string
const c = foo(3); // c: string
numberを受け取り,"one" | "two" | "other"を返す
先ほどの例でも十分ではあるのですが,より正確に表現するのであれば次のような関数シグネチャを付与してみると良いでしょう.
function foo(n: number): "one" | "two" | "other";
function foo(n: number): string {
// ...
}
const a = foo(1); // a: "one" | "two" | "other"
const b = foo(2); // b: "one" | "two" | "other"
const c = foo(3); // c: "one" | "two" | "other"
1を受け取り,"one"を返す
引数が1の時は"one",2の時は"two"を返すことがわかっているため,さらに関数シグネチャを追加してみます.
function foo(n: 1): "one";
function foo(n: 2): "two";
function foo(n: number): "one" | "two" | "other";
function foo(n: number): string {
// ...
}
一見function foo(n: number): "other"という関数シグネチャを追加しても良いように見えますが,numberの中に1や2が含まれていないことを保証できないため,"one" | "two" | "other"とする必要があります.
const a = foo(1); // a: "one"
const b = foo(2); // b: "two"
const c = foo(3); // c: "one" | "two" | "other"
おまけ: どうしても3の時に"other"を返すことを型レベルで表現したい
どうしてもという場合,次のようにジェネリクスを活用することで3の時に"other"を返すことを型を表現することができます.
type IsLiteral<T extends number> = number extends T ? false : true;
function foo<N extends number>(n: N): IsLiteral<N> extends true
? N extends 1
? "one"
: N extends 2
? "two"
: "other"
: "one" | "two" | "other" {
// ...
}
declare const n: number;
const a = foo(1); // a: "one"
const b = foo(2); // b: "two"
const c = foo(3); // c: "other"
const d = foo(n); // d: "one" | "two" | "other"
なんだか読みにくい実装になってしまいましたが,オーバーロード関数にすることで複雑さを軽減することができます.
type IsLiteral<T extends number> = number extends T ? false : true;
function foo(n: 1): "one";
function foo(n: 2): "two";
function foo<N extends number>(n: N): IsLiteral<N> extends true
? "other"
: "one" | "two" | "other";
function foo(n: number): string {
// ...
}
よくある例: フィボナッチ数
オーバーロード関数の定義は,Haskellの関数定義に似ています.
例えば,サンプルコードでよく見るフィボナッチ数を求める関数fib(n)をHaskellでは次のように実装します.
fib :: Int -> Int
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)
この関数をTSで再現すると,次のような実装になります.
function fib(n: number): number {
if (n === 0) return 0;
if (n === 1) return 1;
return fib(n - 1) + fib(n - 2);
}
const a = fib(0); // a: number
const b = fib(1); // b: number
const c = fib(2); // c: number
これに関数シグネチャを追加してオーバーロード関数にしてみましょう.
function fib(n: 0): 0;
function fib(n: 1): 1;
function fib(n: number): number;
function fib(n: number): number {
// ...
}
なんだかHaskellっぽい見た目になりました.
const a = fib(0); // a: 0
const b = fib(1); // b: 1
const c = fib(2); // c: number
このように,オーバーロード関数は複数の型シグネチャの中から最初にマッチする型シグネチャを採用して型推論を行うため,Haskellの関数定義のようなパターンマッチを型レベルで表現することができます.
0または1個のnumberを受け取る関数
次のようなJSの関数があったとき,どのような型を付与するのが良いでしょうか?
function foo(n) {
if (!n) return 0;
return n;
}
例えば次のようになるのではないでしょうか?
function foo(n?: number): number {
if (!n) return 0;
return n;
}
const a = foo(); // a: number
const b = foo(1); // b: number
一見悪い型には見えませんが,次の推論を見てみましょう.
declare const n: number | undefined;
const a = foo(n); // a: number
処理の内容によってはこのような呼び出しもあり得るかもしれませんが,表現したいのは「0または1個のnumberを受け取る関数」であって,「numberもしくはundefinedを受け取る関数」を表現したいわけではありません.
オーバーロード関数で表現する
関数シグネチャを利用して,より正確に表現すると,次のような実装になります.
function foo(): 0;
function foo(n: number): number;
function foo(n?: number): number {
// ...
}
const a = foo(); // a: 0
const b = foo(1); // b: number
そしてもちろん,次のケースを型エラーにすることができます.
declare const n: number | undefined;
const a = foo(n);
// ^ Argument of type 'number | undefined' is not assignable to parameter of type 'number'.
// Type 'undefined' is not assignable to type 'number'.
ジェネリクスも併せて活用すると,より正確な型を表現することもできます.
function foo(): 0;
function foo<N extends number>(n: N): N;
function foo(n?: number): number {
// ...
}
declare const n: number;;
const a = foo(); // a: 0
const b = foo(1); // b: 1
const c = foo(n); // c: number
ユニオン型
TSを学ぶときに比較的最初に出会う型定義の1つがユニオン型ではないでしょうか.
type A = number | string;
ユニオン型でも型推論によるパターンマッチを表現することができ,「判別可能なユニオン型(discriminated union)」と呼ばれています.
よくある例: Result型
RustのResult<T, E>のような型をTSで表現するにはどうしたら良いでしょうか?
とりあえず必要な要素を並べてみる
例えばResult<T, E>を表現しようとした時,次のような要素が必要になります.
type Result<T, E> = {
ok: boolean;
value?: T;
error?: E;
};
この型は,成功ok = trueの場合はvalueが存在し,失敗ok = falseの場合はerrorが存在することを期待しています.
ですが,この書き方ですとok = trueの時にvalueがあることを保証できず,ok = falseの時にerrorがあることを保証できないため,次のような実装になってしまいます.
declare const result: Result<number, string>;
declare function whenOk(n: number): void;
declare function whenErr(msg: string): void;
if (result.ok) {
// 運用でカバー
whenOk(result.value ?? 0);
} else {
whenErr(result.error ?? "Unknown error");
}
// めちゃくちゃなResult型
const a: Result<number, string> = { ok: true, error: "Error!" };
const b: Result<number, string> = { ok: false, value: 1};
const c: Result<number, string> = { ok: true, value: 1, error: "Error!" };
Ok<T>とErr<E>に型を分ける
そこで,Result<T, E>を次のようにOk<T>とErr<E>に分け,ユニオン型で表現してみましょう.
type Ok<T> = {
ok: true;
value: T;
};
type Err<E> = {
ok: false;
error: E;
};
type Result<T, E> = Ok<T> | Err<E>;
すると,次のようなケースは型エラーになります.
const a: Result<number, string> = { ok: true, error: "Error!" };
// ^^^^^ Object literal may only specify known properties, and 'error' does not exist in type 'Ok<number>'.
const b: Result<number, string> = { ok: false, value: 1};
// ^^^^^ Object literal may only specify known properties, and 'value' does not exist in type 'Err<string>'.
const c: Result<number, string> = { ok: true, value: 1, error: "Error!" };
// ^^^^^ Object literal may only specify known properties, and 'error' does not exist in type 'Ok<number>'.
また,ok = trueの時はvalueが存在し,ok = falseの時はerrorが存在することが型レベルで保証されるため,次のように安全にアクセスすることができます.
if (result.ok) {
whenOk(result.value);
} else {
whenErr(result.error);
}
これを「判別可能なユニオン型」と呼びます.
複雑な関数をシンプルな関数に分解する例
例えば,次のような関数があります.
function foo(value: string | number | boolean) {
if (typeof value === "string") {
// ...
} else if (typeof value === "number") {
// ...
} else {
// ...
}
}
この関数の処理をシンプルにするために,この関数を3つの関数に分解します.
function whenString(value: string) {
// ...
}
function whenNumber(value: number) {
// ...
}
function whenBoolean(value: boolean) {
// ...
}
この場合,これらの関数を呼び出す実装は次のようになるでしょう.
decllare const value: string | number | boolean;
if (typeof value === "string") {
whenString(value);
} else if (typeof value === "number") {
whenNumber(value);
} else {
whenBoolean(value);
}
判別可能なユニオン型で表現する
これらを判別可能なユニオン型を利用して表現してみます.
type MyString = {
type: "string";
value: string;
};
type MyNumber = {
type: "number";
value: number;
};
type MyBoolean = {
type: "boolean";
value: boolean;
};
type MyValue = MyString | MyNumber | MyBoolean;
これらの型を利用すると,次のようにtypeの値に応じてvalueの型を推論することができます.
declare const myValue: MyValue;
switch (myValue.type) {
case "string":
myValue.value; // value: string
break;
case "number":
myValue.value; // value: number
break;
case "boolean":
myValue.value; // value: boolean
break;
}
そのため,3つに分けた関数の呼び出し判定を次のようにシンプルに書くことができます.
switch (myValue.type) {
case "string":
whenString(myValue.value);
break;
case "number":
whenNumber(myValue.value);
break;
case "boolean":
whenBoolean(myValue.value);
break;
}
この例のvalueはシンプルなプリミティブ型なため,ただ冗長な実装に見えますが,これが複雑なドメインオブジェクト型などの場合に「判別可能なユニオン型」は活きてきます.
type A = { type: "A"; /* ... */ };
type B = { type: "B"; /* ... */ };
type C = { type: "C"; /* ... */ };
switch (obj.type) {
case "A":
whenA(obj);
break;
case "B":
whenB(obj);
break;
case "C":
whenC(obj);
break;
}
まとめ
このようにオーバーロード関数やユニオン型をうまく活用することで,TSでは型レベルのパターンマッチを表現することができます.
TSの型推論をうまく活用できると,コアドメインに近づくほど型がより確定していくような実装を書くことができるようになるため,単に型レベルで安全になるだけでなく,型チェックのための判別式をなどを減らすこともできます.
安全かつシンプルなJSを目指して活用していきましょう🥳
Discussion