Rust愛好家の私が、JavaScriptにもあったら良いなと思うRustの機能をちょい足ししていくシリーズ(予定)です。
今回は、JavaScriptの switch
文の上位互換とも言うべきRustの match
式風の match
関数をちょい足ししてみます。
対象読者
JavaScript初心者でも読めるように書いてみましたので、JavaScriptに少しでも馴染みのある方すべてを対象にしています。
TypeScriptをお使いの方には更に追加ポイントとして、今回紹介する match
関数がいかに型安全性を実現しプログラムのエラーをいち早く検知できるようになっているか、その裏側を解説いたします。
TypeScriptに詳しくない方は読み飛ばせるようになっているので、ご心配なく。
また、Rustに関する知識は一切必要ありませんので、その点もご心配なく。
JavaScriptの switch
文、使いづらくないですか?
まずは例として「サブスクリプションサービスの契約ステータス」をプログラム上で表現することを考えてみます。
ここでは、契約ステータスには以下の3種類があるとします。
- 契約中
- 過去に契約していたが、現在は解約済み
- 一度も契約したことがない
このステータスを元に、(例えばWebアプリのフロントエンドなどに表示する)日本語のメッセージを表示することを考えます。
さらに、それぞれのステータスごとに追加で以下の情報も表示したいとしましょう。
- 契約中
- 次回契約更新日時
- 契約の自動更新が有効か
- 過去に契約していたが、現在は解約済み
- 解約日時
- 一度も契約したことがない
(「一度も契約したことがない」ステータスの場合は追加の情報はありません。)
では、JavaScriptコードを書いていきましょう。
はじめに、それぞれのステータスを表すオブジェクトを変数として定義してみます。
// 「契約中」ステータス。
const subscribed = {
// ステータスを区別するための項目。
kind: "subscribed",
// 次回契約更新日時。
nextRenewalDate: new Date(),
// 契約の自動更新が有効か。
autoRenewalEnabled: false,
};
// 「過去に契約していたが、現在は解約済み」ステータス。
const subscriptionStopped = {
kind: "subscriptionStopped",
// 解約日時。
stopTime: new Date(),
};
// 「一度も契約したことがない」ステータス。
const neverSubscribed = {
kind: "neverSubscribed",
};
ここでは、それぞれのオブジェクトの kind
プロパティを基準にどのステータスであるかを区別するという想定をしています。
こういった目的に kind
ではなく type
という名前のプロパティを使う方もいますが、JavaScriptの世界における「型」と名前が衝突してしまっているので私は避けています。
(TypeScriptを使っていないからといって型という概念が存在しないわけではないですよね。typeof subscribed === "object"
ですから。)
続いて、これらのステータスを日本語メッセージに変換する部分の処理を switch
文を使って書いてみます。
// 契約ステータスの日本語メッセージを返す。
const describeStatus = (status) => {
switch (status.kind) {
case "subscribed":
return `サブスクリプション契約中です。${toDateString(status.nextRenewalDate)}に${
status.autoRenewalEnabled ? "自動更新されます。" : "無効となります。"
}`;
case "subscriptionStopped":
return `サブスクリプション契約は${toDateString(status.stopTime)}に解約されました。`;
case "neverSubscribed":
return "サブスクリプション未契約です。";
}
};
// Dateオブジェクトを `YYYY/MM/DD` 形式でフォーマットする。
const toDateString = (date) =>
date.toLocaleDateString("ja-JP", { dateStyle: "medium" });
それぞれの case
節が break
で終わらないことに違和感を感じられる方もいるかもしれません。
case
節が break
で終わることが一般的なのは事実ですが、 break
以外の方法で switch
文から抜け出すこともよくあります。
この例で使っている return
以外にも、 throw
で例外を発生させる方法などもあります(後ほど登場します)。
return
の次の行に break
を書いても動かなくなることはありませんが、その行に処理が到達することはないので無駄にキーボード叩いていることになります。
そして最後に、 describeStatus
を使ってメッセージをコンソールに出力してみます。
console.log(describeStatus(subscribed));
// → サブスクリプション契約中です。2023/02/09に無効となります。
console.log(describeStatus(subscriptionStopped));
// → サブスクリプション契約は2023/02/09に解約されました。
console.log(describeStatus(neverSubscribed));
// → サブスクリプション未契約です。
ここまで読んで、この switch
文を使った例のどこが悪いのかとお感じの方もいらっしゃると思います。
正直なところ、このようなJavaScriptのコードは普通に見かけますし、私自身この switch
文を見て「悪いコード」とは一切思いません。
それでも、Rustの match
式の良さを一度味わってしまった私には、JavaScriptの switch
文には「これじゃない感」を感じる点が3つほどあるのです。
1. switch
「文」 vs. match
「式」
なんとなく読んでいると気づきにくい話ではあるのですが、ここまでJavaScriptの switch
「文」とRustの match
「式」と言葉を使い分けしていたことに気づいた方もいらっしゃると思います。
私は細かい定義の話は好きじゃないので、次の例を見ていただきたいです。
この例は正しいJavaScriptでないので、動きません。
const message = switch (status.kind) {
case "subscribed":
`サブスクリプション契約中です。${toDateString(status.nextRenewalDate)}に${
status.autoRenewalEnabled ? "自動更新されます。" : "無効となります。"
}`;
case "subscriptionStopped":
`サブスクリプション契約は${toDateString(status.stopTime)}に解約されました。`;
case "neverSubscribed":
"サブスクリプション未契約です。";
}
これは、JavaScriptの switch
が「文」であるにも関わらず「式」として使おうとしているため、正しいJavaScriptコードではありません。
式は、プログラム実行時にその計算結果の値に置き換わる(「式が評価される」とも言いますが)ものです。
例えば、 2 + 2
は実行時に計算結果の 4
に置き換わるので式です。
parseInt("0xff", 16)
のような関数の呼び出しも式です(ここでは 255
に置き換わりますね)。
逆に、 switch
や for
のような構文は文であるため、それ自体に計算結果の値というものは存在しません。
このため、上の例のように「変数に値を代入する」場面での値としては式を使わなければならず、文を使った場合は文法エラーとなってしまいます。
どの機能が式か文かというのはプログラミング言語によって意見が分かれる部分ではあります。
一昔前はRubyの case
(JavaScriptの switch
と役割は同じ)や if
が文ではなく式であることでRubyの表現性の高さが売りにもなっていましたが、KotlinやRustなどの比較的新しめの言語は文よりも式を好む傾向が強くなってきていると感じます。
今回の記事の元となったRustの match
も式になっています。
次の例を見ていただければ雰囲気は掴めるかと思います。
let message = match status {
Status::Subscribed => "サブスクリプション契約中です。",
Status::SubscriptionStopped => "サブスクリプション契約は解約されました。",
Status::NeverSubscribed => "サブスクリプション未契約です。",
};
switch
式のようなものをJavaScriptで実現したい場合、 switch
文を関数式に包み込んで、その関数式を直ちに実行するという方法があります。
const message = (() => {
switch (status.kind) {
case "subscribed":
return `サブスクリプション契約中です。${toDateString(status.nextRenewalDate)}に${
status.autoRenewalEnabled ? "自動更新されます。" : "無効となります。"
}`;
case "subscriptionStopped":
return `サブスクリプション契約は${toDateString(status.stopTime)}に解約されました。`;
case "neverSubscribed":
return "サブスクリプション未契約です。";
}
})();
今回の例に限らず頻繁に使われることの多いパターンなので、「即時実行関数」という名前まであります。
ある程度JavaScriptに慣れてしまえば戸惑うこともないですが、私が初心者だった頃は謎の暗号を読み解いているような気分になった思い出があります。
こんな初心者泣かせの機能を使うのが定石な世の中にはしたくないじゃないですよね?
これが、Rust風 match
をJavaScriptにちょい足ししたいと思った1つめの理由です。
2. 全パターンを網羅していない場合でも気づきにくい
仮に開発を進めていく過程で契約ステータスの種類が3種類から4種類になったとします。
例えば、「契約不可能(kind = "subscriptionUnavailable"
)」を表すステータスを追加することを考えましょう。
const subscriptionUnavailable = {
kind: "subscriptionUnavailable",
};
ここで、本来であれば契約ステータスを扱っている部分のコードすべてを「契約不可能」ステータスの場合に対応できるよう書き直さなければならないですが、プログラマの不注意で修正し忘れてしまう場合もあるでしょう。
一例として、先程使った describeStatus
を「契約不可能」ステータスに対応せずに「契約不可能」ステータスを引数として渡した場合を考えてみます。
const describeStatus = (status) => {
switch (status.kind) {
case "subscribed":
return "サブスクリプション契約中です。";
case "subscriptionStopped":
return "サブスクリプション契約は解約されました。";
case "neverSubscribed":
return "サブスクリプション未契約です。";
// subscriptionUnavailableの場合を追加していない!
}
};
console.log(describeStatus(subscriptionUnavailable));
// → undefined
ご覧の通り、switch
文で kind
が subscriptionUnavailable
の場合を扱っていないため、いずれの case
節も実行されずに関数の終端に到達することになります。
この場合、 return
文で具体的な値を返さずに関数の終端に到達したため、関数の戻り値は undefined
となります。
関数の戻り値を後続の処理でどう使っているか次第ではありますが、例えば戻り値をそのままフロントエンドの画面に埋め込んでる場合ですと、 undefined
が空の文字列に変換されるためエラーにならない、エラーにならないため修正し忘れたことにも気づけないといった結果になるかもしれません。
このような場合にエラーが発生するよう、 default
節で kind
の値が想定外だった場合に例外を発生させるというのも一つの手です。
const describeStatus = (status) => {
switch (status.kind) {
case "subscribed":
return "サブスクリプション契約中です。";
case "subscriptionStopped":
return "サブスクリプション契約は解約されました。";
case "neverSubscribed":
return "サブスクリプション未契約です。";
default:
// subscriptionUnavailableの場合に実行される。
throw new Error(`未対応の契約ステータス${status.kind}が指定されました。`);
}
};
この default
節は status.kind
が3つの case
節のいずれにも当てはまらない場合のみ実行されるため、 subscriptionUnavailable
の case
節を追加せずに「契約不可能」の値で describeStatus
を実行した場合は例外が発生することとなります。
この方法を使えば全パターンを網羅できない場合の検知がしやすくなるのは事実ですが、すべての switch
文にこの例外処理を追加するのは手間ですし、追加すること自体を忘れてしまう可能性さえあります。
このような、不注意による不具合の見逃しを少なくしたいというのが、Rust風 match
をJavaScriptに追加したいと思った2つ目の理由です。
3. 記法がヘビー
Rustが使ってて気持ちの良い言語である理由の一つに、「タイプ量が少ない」という点があると思います。
型のタイプではなく、キーボードのタイプです。
その理由の一つとして、頻繁に使われるキーワードの多くが2・3文字のみに抑えられているという点が挙げられます。
以下、いくつか例を挙げてみます。
JavaScript | Rust |
---|---|
function
|
fn
|
const
|
let
|
import
|
use
|
export
|
pub
|
たかがキーストローク数回少ないだけともいえますが、塵も積もれば山となるというものです。
また、単なるキーワードの文字数という話だけではありません。
ここで、JavaScriptの switch
文とRustの match
式を視覚的に比較してみます。
JavaScript | Rust |
---|---|
それぞれの画像で赤でハイライトした部分は、それぞれの処理において「意味のある」部分で、それ以外の部分は言語の記法上書かざるを得ない部分です。
ご覧の通り、Rustの match
式のほうが必要な情報を無駄なくコンパクトに表現できていますよね。
JavaScriptでもこのコンパクトさを実現したいというのが、Rust風 match
をJavaScriptに追加したいと思った3つ目の理由です。
Rust風 match
のご紹介
では、これまで使ってきた契約ステータスの例をRust風 match
関数を使って書き直してみましょう。
(実装方法については「match
関数の実装解説(JavaScript編)」にて紹介します。)
const message = match(status, "kind")({
subscribed: ({ nextRenewalDate, autoRenewalEnabled }) =>
`サブスクリプション契約中です。${toDateString(nextRenewalDate)}に${
autoRenewalEnabled ? "自動更新されます。" : "無効となります。"
}`,
subscriptionStopped: ({ stopTime }) =>
`サブスクリプション契約は${toDateString(stopTime)}に解約されました。`,
neverSubscribed: () => "サブスクリプション未契約です。",
});
// Dateオブジェクトを `YYYY/MM/DD` 形式でフォーマットする。
const toDateString = (date) =>
date.toLocaleDateString("ja-JP", { dateStyle: "medium" });
ご覧の通り、 match
関数の使い方には少々クセがあります。
(後ほどご紹介する「match
関数の実装解説(TypeScript編)」をお読みになれば、理由がお分かりになるはずです。)
match
関数を直接呼び出している match(status, "kind")
部分では、第一引数として契約ステータスオブジェクトを渡しています。
第二引数の "kind"
は、第一引数として渡した契約ステータスオブジェクト内でステータスの区別をするために使っているプロパティの名前です。
例えばステータスの区別に "type"
というプロパティ名を使いたい方は、 match(status, "type")
と書くことになります。
さて、 match(status, "kind")
の直後に丸括弧 (
が続いていますが、これは match(status, "kind")
の戻り値が関数であり、その戻り値の関数を即座に呼び出しているためです。
この2度目の関数呼び出しでは、唯一の引数としてオブジェクトを渡しています。
このオブジェクトは、 status.kind
のとり得る値それぞれについてプロパティが定義されています。
ここでは、 subscribed
, subscriptionStopped
, neverSubscribed
の3つのプロパティを持つオブジェクトを渡していますね。
それぞれのプロパティの値として、ステータスがその値だった場合に実行される関数を指定しています。
この関数の引数には、 match(status, "kind")
の第一引数である status
がそのまま渡されます。
例えば subscribed
に指定された関数では、引数の status
から「分割代入」を使って nextRenewalDate
と autoRenewalEnabled
を取り出し、それらを元にメッセージを組み立てていますね。
subscriptionStopped
でもこのステータス固有の stopTime
を分割代入で取り出しています。
neverSubscribed
ではステータス固有のプロパティがなく引数を受け取る意味がないので、引数を取らない関数を指定しています。
この match
は関数を使って実現しているため、上の例のように関数呼び出しの結果をそのまま変数に代入でき、文ではなく式として使われていることがお分かりと思います。
また、全パターンを網羅できていなかった場合についても考えてみましょう。
const describeStatus = (status) => {
return match(status, "kind")({
subscribed: () => "サブスクリプション契約中です。",
subscriptionStopped: () => "サブスクリプション契約は解約されました。",
neverSubscribed: () => "サブスクリプション未契約です。",
});
};
const subscriptionUnavailable = {
kind: "subscriptionUnavailable",
};
console.log(describeStatus(subscriptionUnavailable));
このコードを実行すると、以下の例外が発生します。
PanicError: match branch missing for kind = subscriptionUnavailable
例外が発生することで、全パターンが網羅できていなかった場合や、タイポしてしまった場合にいち早く間違いに気づくことができるはずです。
さらに、このJavaScriptの switch
文とRust風 match
関数で記法を比べてみます。
JavaScript - switch | JavaScript - match |
---|---|
ご覧の通り、 case
や return
キーワードが省略できることで、比較的無駄が少なめでRustに近い記法になっています。
「文ではなく式である」「網羅性を担保できる」「無駄の少ない記法である」という3つの要件が満たされており、Rust愛好家の私でも気持ちよく使える match
関数になっています。
Rust風 match
の上級者向けの使い方
タプルを使う
契約ステータスをオブジェクトではなくタプルとして表現したい場合にも、今回のRust風 match
を使うことができます。
タプルはJavaScriptでは配列を使って表されることが一般的で、配列の何番目の要素にどの情報を格納するかをあらかじめ決めておく必要があります。
// 「契約中」ステータス。
const subscribed = [
// 0要素目: ステータスを区別するための項目。
"subscribed",
// 1要素目: 次回契約更新日時。
new Date(),
// 2要素目: 契約の自動更新が有効か。
false,
];
// 「過去に契約していたが、現在は解約済み」ステータス。
const subscriptionStopped = [
"subscriptionStopped",
// 1要素目: 解約日時。
new Date(),
];
// 「一度も契約したことがない」ステータス。
const neverSubscribed = ["neverSubscribed"];
ここでは、配列の0番目の要素にどのステータスであるかを表す文字列を、それ以降の要素にそれぞれのステータス固有の情報を格納するようにしています。
この場合、 match(status, "kind")
と書く代わりに match(status, 0)
と書くことで、配列の0番目の要素がステータスを区別するための項目であることを指定できます。
const describeStatus = (status) => {
return match(status, 0)({
subscribed: ([_kind, nextRenewalDate, autoRenewalEnabled]) =>
`サブスクリプション契約中です。${toDateString(nextRenewalDate)}に${
autoRenewalEnabled ? "自動更新されます。" : "無効となります。"
}`,
subscriptionStopped: ([_kind, stopTime]) =>
`サブスクリプション契約は${toDateString(stopTime)}に解約されました。`,
neverSubscribed: () => "サブスクリプション未契約です。",
});
};
// Dateオブジェクトを `YYYY/MM/DD` 形式でフォーマットする。
const toDateString = (date) =>
date.toLocaleDateString("ja-JP", { dateStyle: "medium" });
console.log(describeStatus(subscribed));
// → サブスクリプション契約中です。2023/02/09に無効となります。
console.log(describeStatus(subscriptionStopped));
// → サブスクリプション契約は2023/02/09に解約されました。
console.log(describeStatus(neverSubscribed));
// → サブスクリプション未契約です。
シンボルを使う
ステータスを区別するプロパティのキーには kind
のような文字列のかわりに Symbol
型の値を使うこともできます。
また、ステータスを区別するプロパティの値としても、 Symbol
を使うことができます。
キーと値の両方を Symbol
とした場合の例を以下で見てみましょう。
// ステータスを区別するプロパティのキー。
const kind = Symbol();
// それぞれのステータスでkindキーの値として使うSymbol値。
const subscribedKind = Symbol();
const subscriptionStoppedKind = Symbol();
const neverSubscribedKind = Symbol();
// 「契約中」ステータス。
const subscribed = {
// ステータスを区別するための項目。
[kind]: subscribedKind,
// 次回契約更新日時。
nextRenewalDate: new Date(),
// 契約の自動更新が有効か。
autoRenewalEnabled: false,
};
// 「過去に契約していたが、現在は解約済み」ステータス。
const subscriptionStopped = {
[kind]: subscriptionStoppedKind,
// 解約日時。
stopTime: new Date(),
};
// 「一度も契約したことがない」ステータス。
const neverSubscribed = {
[kind]: neverSubscribedKind,
};
// 契約ステータスの日本語メッセージを返す。
const describeStatus = (status) => {
return match(status, kind)({
[subscribedKind]: ({ nextRenewalDate, autoRenewalEnabled }) =>
`サブスクリプション契約中です。${toDateString(nextRenewalDate)}に${
autoRenewalEnabled ? "自動更新されます。" : "無効となります。"
}`,
[subscriptionStoppedKind]: ({ stopTime }) =>
`サブスクリプション契約は${toDateString(stopTime)}に解約されました。`,
[neverSubscribedKind]: () => "サブスクリプション未契約です。",
});
};
// Dateオブジェクトを `YYYY/MM/DD` 形式でフォーマットする。
const toDateString = (date) =>
date.toLocaleDateString("ja-JP", { dateStyle: "medium" });
console.log(describeStatus(subscribed));
// → サブスクリプション契約中です。2023/02/09に無効となります。
console.log(describeStatus(subscriptionStopped));
// → サブスクリプション契約は2023/02/09に解約されました。
console.log(describeStatus(neverSubscribed));
// → サブスクリプション未契約です。
TypeScriptではさらに match
関数が強力になります
TypeScriptに馴染みがない方も、「型」の存在によって開発効率がいかに向上できるかのデモンストレーションとなっていますので、このセクションは読んでいただきたいです。
TypeScriptでは、今回例として使っている契約ステータスオブジェクトは以下のような型として定義するのが一般的です。
// 「契約中」ステータス。
interface Subscribed {
// ステータスを区別するための項目。
kind: "subscribed";
// 次回契約更新日時。
nextRenewalDate: Date;
// 契約の自動更新が有効か。
autoRenewalEnabled: boolean;
}
// 「過去に契約していたが、現在は解約済み」ステータス。
interface SubscriptionStopped {
kind: "subscriptionStopped";
// 解約日時。
stopTime: Date;
}
// 「一度も契約したことがない」ステータス。
interface NeverSubscribed {
kind: "neverSubscribed";
}
type Status = Subscribed | SubscriptionStopped | NeverSubscribed;
今回紹介しているRust風 match
関数は、このような型情報を活用してさらなる開発効率・安全性を実現することができます。
こちらは、以下のデモンストレーションを見ていただくのが一番分かりやすいかと思います。
注目すべき箇所は2点あります。
1点目は、 match(status, "kind")
の第二引数の "kind"
を自動補完している点です。
これは、第一引数である status
のすべてのステータスで共通するプロパティのキーのみを第二引数として受け付けるように型定義しているため、今回の場合はそれに該当する唯一のプロパティ "kind"
のみが補完候補となっています。
自動補完だけでなく、実在しないプロパティを指定した場合にエラーとなる点も注目です。
2点目は、2回目の関数呼び出しの引数を自動補完している点です。
ここでは、1回目の関数呼び出しの第一引数である status
の型(Status
型)を元に、2回目の関数呼び出しの引数に渡すべきオブジェクトの型を導き出しているため、このような補完が可能になっています。
こちらは、全ステータスを網羅しなかった場合にエラーとなる点にも注目です。
お世辞にも読みやすいエラーメッセージとは言えませんが、赤でハイライトした部分を読めば neverSubscribed
ステータスの場合の処理を書き忘れていることが分かるはずです。
このように、TypeScriptを使うことによって開発効率の向上のみならず、JavaScriptのみではいざ実行してエラーになってからでないと気づけないような間違いにも、開発中の早い段階で気づけるようになるという利点があります。
これは今回紹介しているRust風 match
関数に限らず全般的に言えることですので、TypeScriptに興味を持っていただけた方はこれを機に勉強してみてはいかがでしょうか?
match
関数の実装解説(JavaScript編)
使い方の紹介をしたところで、実際にこのRust風 match
関数をいかに実装するかを解説していきます。
まずはこのセクションでJavaScriptを使って実装する方法を解説し、その次のセクションでTypeScriptを使った実装方法も紹介していきたいと思います。
JavaScript版の実装は、ご覧の通りいたって簡単です。
export const match = (input, tagKey) => (branches) => {
const tag = input[tagKey];
const branchForTag = branches[tag];
if (!branchForTag) {
throw new Error(
`match branch missing for ${String(tagKey)} = ${String(tag)}`
);
}
return branchForTag(input);
};
使い方の紹介で「match
関数の戻り値である関数を即時に呼び出す」というクセのある使い方を紹介しましたが、この実装を見ると match
が戻り値として関数を返す関数であることが見てとれると思います。
分かりにくいと思った方のために、以下のように書き直してみましょう。
export const match = (input, tagKey) => {
const executeBranch = (branches) => {
const tag = input[tagKey];
const branchForTag = branches[tag];
if (!branchForTag) {
throw new Error(
`match branch missing for ${String(tagKey)} = ${String(tag)}`
);
}
return branchForTag(input);
};
return executeBranch;
};
executeBranch
関数が2回目の関数呼び出しにて呼び出される関数だということが分かりやすくなったと思います。
また、1回目の関数呼び出しでは input
と tagKey
パラメータを受け取り、 executeBranch
関数を作っているだけなので、重要な処理の本体は executeBranch
関数に集約されていることも分かるでしょう。
では、肝心の executeBranch
関数の中身を見てみます。
まず1回目の関数呼び出しで受け取った input
と tagKey
はそれぞれ、「入力オブジェクト(例では「契約ステータスを表すオブジェクト」)」と「入力オブジェクトの種別を区別するためにつかうプロパティのキー(例では kind
)」を表します。
「入力オブジェクトの種別を区別するために使うプロパティ」だと長ったらしいので、簡潔にタグ(tag
)と呼んでいます。
2回目の関数呼び出しで受け取った branches
は、それぞれのタグごとにタグの値(subscribed
など)をキーとし、入力オブジェクトとそのタグの値が一致した場合に実行される関数を値とするオブジェクトを表します。
const tag = input[tagKey];
では入力オブジェクトからタグの値を取り出しており、その値を元にどの関数を実行すべきかを const branchForTag = branches[tag];
で決定しています。
もし該当する関数がなかった場合は全パターンが網羅できていないので、プログラムに誤りがあるとみなして例外を発生させています(if
文の中身)。
該当する関数があった場合は、その関数に入力オブジェクトを渡して実行し、その戻り値を executeBranch
関数そのものの戻り値として返しています。
関数が2回呼び出されることを除けばそこまで複雑な処理はないかと思います。
match
関数の実装解説(TypeScript編)
TypeScriptにそこそこ自信のある方以外はこのセクションは読み飛ばしていただくことをおすすめします。
JavaScript版の実装のみでは、先程紹介した自動補完や型安全性の担保は実現できません。
このセクションでは、先程のJavaScript実装に型情報を加えていき、自動補完・型安全性をいかに実現していくかを解説していきます。
まず、お助け型として、JavaScriptにおいてオブジェクトのキーとなり得る型を表す ObjectKey
型を定義しましょう。
type ObjectKey = string | symbol | number;
そして、もう一つのお助け型として、入力オブジェクトに必要な制約を UnionRecord
型として表現します。
export type UnionRecord<TTagKey extends ObjectKey> = Record<TTagKey, ObjectKey>;
TTagKey
型引数には "kind"
のようなタグのキーを表すリテラル型が入ることになります。
試しに TTagKey = "kind"
として型を展開していくと、以下のようになります。
type U1 = UnionRecord<"kind">;
type U2 = Record<"kind", string | symbol | number>;
type U3 = {
kind: string | symbol | number;
}
つまり、タグのキーが "kind"
である入力オブジェクトに必要な制約とは、 kind
プロパティの値が string | symbol | number
型であること、と言えます。
タグの値の型に制約があるのは、関数の2回目の呼び出しの際にそれぞれのタグの値をキーとするオブジェクトを渡す必要があるためです。
さて、続けて match
関数の本体に型情報を追加していきます。
まずは1回目に呼び出される関数にのみ、型情報を追加してみます。
- export const match = (input, tagKey) => {
+ export const match = <
+ TUnion extends UnionRecord<TTagKey>,
+ TTagKey extends keyof TUnion
+>(
+ input: TUnion,
+ tagKey: TTagKey
+) => {
const executeBranch = (branches) => {
const tag = input[tagKey];
const branchForTag = branches[tag];
if (!branchForTag) {
throw new Error(
`match branch missing for ${String(tagKey)} = ${String(tag)}`
);
}
return branchForTag(input);
};
return executeBranch;
};
先程定義した UnionRecord
型が型引数 TUnion
の型制約として登場していますね。
これにより、 input: TUnion
引数には先程触れたタグの制約を満たすオブジェクトのみしか受け付けないようになります。
さらに、型引数 TTagKey
の型制約には TUnion
のキーであることが指定されています。
これにより、実在しないプロパティをタグのキーとして指定された場合にエラーを検出できるようになっています。
match
関数を呼び出す際、型引数 TTagKey
の値は引数 tagKey
の値を元に型推論されます。
例えば、 match(status, "kind")
と呼び出した場合は TTagKey = "kind"
と型推論されます。
この TTagKey
の値はさらに型引数 TUnion
の型制約 UnionRecord<TTagKey>
にも使われ、引数 input
に適切なタグのキー・値が存在することのチェックが行われます。
続けて、2回目に呼び出される関数(executeBranch
)に型情報を追加していきます。
先程より若干複雑なので、まずは引数 branches
にのみ型情報を追加し、その上で戻り値に型情報を追加するという2段階に分けて考えていきましょう。
export const match = <
TUnion extends UnionRecord<TTagKey>,
TTagKey extends keyof TUnion
>(
input: TUnion,
tagKey: TTagKey
) => {
- const executeBranch = (branches) => {
+ const executeBranch = <TBranches extends MatchBranches<TUnion, TTagKey>>(
+ branches: TBranches
+ ) => {
const tag = input[tagKey];
const branchForTag = branches[tag];
if (!branchForTag) {
throw new Error(
`match branch missing for ${String(tagKey)} = ${String(tag)}`
);
}
return branchForTag(input);
};
return executeBranch;
};
型引数 TBranches
の型制約に使われている MatchBranches
は以下のように定義されます。
export type MatchBranches<
TUnion extends UnionRecord<TTagKey>,
TTagKey extends keyof TUnion
> = {
[TagValue in TUnion[TTagKey]]: (
value: VariantForTag<TUnion, TTagKey, TagValue>
) => unknown;
};
ここでは、入力オブジェクトの型のタグのキーを元に、 Mapped Types を使って2回目の関数呼び出しの引数の型を導き出しています。
TUnion[TTagKey]
はタグのとり得る値を表すユニオン型に相当し、今回の例では "subscribed" | "subscriptionStopped" | "neverSubscribed"
となります。
そのユニオンの構成要素それぞれを TagValue
として取り出しながら、その TagValue
をキー、 (value: VariantForTag<TUnion, TTagKey, TagValue>) => unknown;
という関数を値とするオブジェクトを組み立てるのが、この MatchBranches
型です。
試しに今回の例を使って型を展開していくと、以下のようになります。
type MB1 = MatchBranches<Status, "kind">;
type MB2 = {
[TagValue in "subscribed" | "subscriptionStopped" | "neverSubscribed"]: (value: VariantForTag<Status, "kind", TagValue>) => unknown,
}
type MB3 = {
subscribed: (value: VariantForTag<Status, "kind", "subscribed">) => unknown,
subscriptionStopped: (value: VariantForTag<Status, "kind", "subscriptionStopped">) => unknown,
neverSubscribed: (value: VariantForTag<Status, "kind", "neverSubscribed">) => unknown,
}
VariantForTag
型についても見てみましょう。
export type VariantForTag<
TUnion extends UnionRecord<TTagKey>,
TTagKey extends keyof TUnion,
TTagValue extends TUnion[TTagKey]
> = Extract<
TUnion,
{
[TagKey in TTagKey]: TTagValue;
}
>;
この型では、入力オブジェクトの型 TUnion
から、タグのキーが TTagKey
、値が TTagValue
である型のみを抜き出す(Extract
)型操作を行っています。
今回の例でタグの値が subscribed
の場合を使って型を展開していってみましょう。
type VFT1 = VariantForTag<Status, "kind", "subscribed">;
type VFT2 = Extract<Status, { kind: "subscribed" }>;
type VFT3 = Subscribed;
これら MatchBranches
と VariantForTag
の2つの型の合わせ技によって、 branches
引数の型安全性が担保され、プロパティの自動補完や網羅性検知が実現できているのです。
では、最後に executeBranch
関数の戻り値に型情報を追加してみましょう。
export const match = <
TUnion extends UnionRecord<TTagKey>,
TTagKey extends keyof TUnion
>(
input: TUnion,
tagKey: TTagKey
) => {
const executeBranch = <TBranches extends MatchBranches<TUnion, TTagKey>>(
branches: TBranches
- ) => {
+ ): MatchOutput<TUnion, TTagKey, TBranches> => {
const tag = input[tagKey];
const branchForTag = branches[tag];
if (!branchForTag) {
throw new Error(
`match branch missing for ${String(tagKey)} = ${String(tag)}`
);
}
- return branchForTag(input);
+ return (
+ branchForTag as (input: TUnion) => MatchOutput<TUnion, TTagKey, TBranches>
+ )(input);
};
return executeBranch;
};
戻り値の型に使われている MatchOutput
型の定義は以下のとおりです。
export type MatchOutput<
TUnion extends UnionRecord<TTagKey>,
TTagKey extends keyof TUnion,
TBranches extends MatchBranches<TUnion, TTagKey>
> = ReturnType<TBranches[keyof TBranches]>;
ここでは、 branches
引数の型として推論された TBranches
型を3つ目の型引数として受け取り、その戻り値を ReturnType
型を使って抜き出しています。
先程定義した MatchBranches
ではそれぞれの関数の戻り値は unknown
となっていましたが、2回目の関数呼び出し時に branches
引数を元に型推論される TBranches
型にはそれぞれの関数の具体的な戻り値の型が入っているため、 MatchOutput
で抜き出される型もその具体的な型を元に抜き出されることとなります。
例えば、以下の使用例をご覧ください。
const result = match(status, "kind")({
subscribed: () => "string",
subscriptionStopped: () => 42,
neverSubscribed: () => /regexp/,
});
type ResultType = typeof result;
// → type ResultType = string | number | RegExp
それぞれの関数の戻り値が異なり、かつどこにも明示的に型を指定している部分はありませんが、それでも型推論の結果 result
変数の型を string | number | RegExp
として正しく推論できています。
以上が、Rust風 match
関数TypeScript版の定義の解説となります。
今回紹介したコードは GitHubに公開してあるコード を元にしていますので、興味のある方はそちらもご覧ください。
あとがき
今回紹介した match
関数はnpmパッケージminrs(GitHubレポジトリ)として公開しています。
これから少しずつ機能を追加していく予定ではありますが、現時点ではまだ単体テストもない状態のためパッケージの品質は担保できないのが正直なところです。
また、このパッケージはTypeScript・Rustの機能紹介を目的とする思考実験的な位置づけにすぎないので、将来的に実用レベルのパッケージに育んでいくという思惑は今のところありません。
この記事を読んでご自身のJavaScriptプロジェクトにも match
関数のようなものがほしいと思った方も、現時点ではminrsパッケージに依存することはおすすめしていません。
その代わりに、 match.ts を(必要に応じて自己流にアレンジした上で)プロジェクトに追加していただくことをおすすめします。