はいさい!ちゅらデータぬオースティンやいびーん!
概要
TypeScriptで文字列からキーを抽出し、オブジェクトの型(type
)に変換する方法を紹介します。
背景
Express.jsなどのフレームワークで以下のようなコードをよく書きます。
app.get("/:id", (req, res) => res.send(req.params.id));
読者に気づいた方はいらっしゃると思いますが、このreq.params.id
は、TypeScriptで書いていると、req.params
の型に含まれています。
つまり、req.params
は以下のような型になっています。
type Params = { id: string } & { [name: string]: string};
req.params.id
をVS Codeで呼ぼうとすると、intellisenseで推測してくれるのではないでしょうか?
"/:id"
と書いただけで、なぜTypeScriptは推測できるようになっているのだろうか気になりませんか?
どうなっているのか、以下解説します。
TypeScriptの技術
上記の推測を実現するためにはTypeScriptの4つの機能が使われています。
Generic Types (ジェネリック型)
TypeScriptをよくご存知の方ならば、一度は使って遊んだことがあるかと思います。
例えば、以下のようにdoNothing
という、引数を返すだけの関数を定義するとしましょう。
引数の型に合わせて返す値の型を設定したいです。
- 文字列を引数に渡したら、文字列を返す
- 数値を引数に渡したら、数値を返す
すると、Generic Typesを使うと、上記のような型を書くことができます。
function doNothing<T>(value: T): T {
return value
}
const doNothingArrow = <T>(value: T) => value; // Arrow関数の場合はこのような構文
これはよくFunction Overloadsで使用されますが、今回は下記の技術と一緒に使います。
Template Literal Types (テンプレート・リテラル型)
実は、TypeScipt v4.1から、文字列から型を抽出する機能が入っていたのです。
上記の正式ドキュメントの説明を借りますと、
type World = "world";
type Greeting = `hello ${World}`; // Greeting: "hello world"
このように、型の定義に違う型をはめられることが後々役立ってきます。
Conditional Types (条件的型)
次のTypeScript技術は、条件によって性質が変わる型の定義です。
上記の正式ドキュメントの例を拝借しますと、以下のような型を書くことができます。
type TypeName<T> = T extends string ? string : unknown;
type T0 = TypeName<string>; // type T0 = string
type T1 = TypeName<"a">; // type T1 = string
type T2 = TypeName<number> // type T2 = unknown
type T3 = TypeName<1> // type T3 = unknown
T
というGeneric Typeを定義し、それがもしstring
という型から成っていれば、string
として認めるが、それ以外だと、わからないという意味でunknown
にします。
すると、文字列をGeneric Typeの引数に渡してT1
という型を定義すると、その型定義の結果がstring
になります。それ以外の値だと、unknown
になります。
この機能もまた、後ほど役立ちますので、脇に置いておきましょう。
条件的型におけるType inference (型推測)
最後に必要なTypeScript機能は、条件的型の定義に使える、型を推測する機能です。
正式ドキュメントの例ですが、以下のようにオブジェクトの中に入っているa
とb
のキーの値の型を推測し、型として定義しています。
type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;
type T10 = Foo<{ a: string; b: string }>; // string
type T11 = Foo<{ a: string; b: number }>; // string | number
型から、型を抽出する、これを覚えていただければと思います。
ExpressのParam推測を自作してみる
これからは、上記でご紹介したTypeScript機能を使い、Expressのパラメーターの型を推測する機能を再現してみます。
:
が入っている文字列から型を抽出する
まず、Expressの場合、:
が入っている文字列のみ、パラメーターとして認定しているので、:
が入っているかどうかをチェックする条件的型を定義します。
type GetTypeIfHasColon<T> = T extends `:${infer U}` ? U : never;
type T0 = GetTypeIfHasColon<":aa">; // "aa"
type T1 = GetTypeIfHasColon<"aa">; // never
ここで、テンプレート・リテラルで、正規表現のようなチェックをしています。正規表現だと、/:+/
のような書き方になるかと思います。
その正規表現チェックが通れば、そのセミコロンを省いた文字列の型を推測し、返す。でなければ、never
を返す。
ちなみに、never
は、あり得ない処理、あり得ない結果の時に使われる型。この場合は、never
を使って、TypeScriptに、セミコロンのパターンに一致しなかったら、無視していいと指定しています。
/
が入っている文字列から型を抽出する
次に解決しないといけない問題は、Expressのパスに入っているバックスラッシュ/
をどうするかです。
まず、上記と同じように、テンプレート・リテラルで型を推測するようにしましょう。
type SplitPath<T> = T extends `${infer U}/${infer V}` ? U | V : T;
type T2 = SplitPath<"/:aa"> // "" | ":aa"
type T3 = SplitPath<":aa"> // ":aa"
type T4 = SplitPath<"/:aa/data"> // "" | ":aa/data"
T4
の結果が、望ましくないですね。ここで必要なのは、再起的な処理です。
type SplitPathRecursive<T> = T extends `${infer U}/${infer V}` ? U | SplitPathRecursive<V> : T;
type T2 = SplitPathRecursive<"/:aa"> // "" | ":aa"
type T3 = SplitPathRecursive<":aa"> // ":aa"
type T4 = SplitPathRecursive<"/:aa/data"> // "" | "data" | ":aa"
こうすると/
がたくさんあっても検知できるようになりました!
:
と/
のテンプレート・リテラル型を合わせる
最後に、上記のSplitPathRecursive
にGetTypeIfHasColon
を組み込みます。
type SplitPathRecursive<T> = T extends `${infer U}/${infer V}`
? GetTypeIfHasColon<U> | SplitPathRecursive<V>
: GetTypeIfHasColon<T>;
type T2 = SplitPathRecursive<"/:aa">; // "aa"
type T3 = SplitPathRecursive<":aa">; // "aa"
type T4 = SplitPathRecursive<"/:aa/data">; // "aa"
これで、Expressらしくなってきましたね!ただ、Expressだと、req.params
のように、オブジェクトになっていますので、もう少し頑張りましょう。
Record
の効用型を使う
文字列の型"a"
を、オブジェクトのキーになる型を作ることができます。Record
という、TypeScriptに内蔵されているUtility Type(効用型)を使います。
type MyRecord = Record<"a", string> // { a: string }
これを上記のSplitPathRecursive
と併用すると、パラメーターのキーにすることができます。値の型が必ずstring
になることもExpressの仕組み上わかっているので以下のようなコードを書きます。
type ExpressInferredParams<T> = Record<SplitPathRecursive<T>, string>;
type T5 = ExpressInferredParams<"/:aa/data">; // { aa: string; }
const params: T5 = { aa: "Hello!" };
もう終わりが見えてきましたね。
パラメーターで指定していないキーでも使えるようにする
Expressだと、POST
リクエストのパラメーターはパスだけでないので、上記の定義だと、TypeScriptインタープリターが知らないパラメーターに対してエラーを吐き出すようになります。
解決するためには、以下のような型とユニオンにすることです。
type ExpressInferredParams<T> = Record<SplitPathRecursive<T>, string>;
type ExpressParams<T> = ExpressInferredParams<T> & { [name: string]: string };
type T5 = ExpressParams<"/:aa/data">;
// ExpressInferredParams<"/:aa/data"> & {
// [name: string]: string;
// }
const params: T5 = { aa: "1", picture: "abc" }
Expressと同じような結果、できましたね!
まとめ
以上、ここまで、TypeScriptの様々な機能を使って、文字列からオブジェクトのキーを型に変換する方法を紹介してきましたが、いかがでしょうか?
実際、Expressのソースコードを見ていないので、このような実装になっているのか分かりませんが、きっと筆者以上に鋭い解決方法を使っているのではないかと思います!
TypeScriptのこういった機能をよく理解して使えるようになりたいものですね。