はじめに
この記事は TypeScript の Template Literal Types と infer キーワードを一緒に用いたときの挙動について実験したものをまとめた記事です。
Template Literal Types や Conditional Types については知っているものとし、この記事では説明は行いません。
結論だけを見たい場合は、以下を参照してください。
Template Literal Types と infer の組み合わせ
Template Literal Types と infer キーワードを組み合わせることで、以下のようにマッチした文字列を取り出して利用することができます。
type ParseDate<Date extends string> =
Date extends `${infer Y}-${infer M}-${infer D}`
? [Y, M, D]
: never
type Test = ParseDate<"2024-06-23">
// type Test = ["2024", "06", "23"]
ここからは Template Literal Types と infer の様々な組み合わせを見ることで理解を深め、内部的な挙動の推測をしていきます。
連続する infer について
まずは、以下のような連続する infer のみで構成された型を考えてみます。
type Separate<S extends string> =
S extends `${infer T}${infer U}${infer V}`
? [T, U, V]
: never
これは、与えられた文字列型を3つに分割する型です。
それぞれの infer にどんな文字が対応するのかを見ていきます。
ある程度の長さの文字列の場合
type Test1 = Separate<"Lorem ipsum">
// type Test1 = ["L", "o", "rem ipsum"]
基本は一文字で、最後の部分に残りの文字列が押し込まれていることから、左から処理を行って、1文字以上の最短マッチとなっていることが分かります。
短い文字列の場合
type Test2 = Separate<"AB">
// type Test2 = ["A", "B", ""]
["", "A", "B"] とはならないようです。
最後の部分が空文字になっています。
先程の推測通りと考えるなら、残りの文字(空文字)が最後に押し込まれている、と捉えられそうです。
もっと短い文字列の場合
type Test3 = Separate<"A">
// type Test3 = never
与えられた文字列型は条件にマッチしませんでした。
["A", "", ""] とはならないようです。
最後以外は1文字以上でなければならない、と推測できます。
上記の結果から infer が連続するとき、最後の部分以外は1文字以上の最短マッチで、最後の部分は0文字以上のマッチとなっていると、推測できます。
infer で Template Literal Types を挟む場合について
次に、以下の型を考えてみます。
type Split<S extends string, Sep extends string> =
S extends `${infer L}${Sep}${infer R}`
? [L, R]
: never
split() メソッドのように "A,B" と "," を与えれば ["A", "B"] を返す型です。
infer となっていない部分(${Sep})がどのような影響を与えるのかを見ていきます。
複数の Sep を含む文字列の場合
type Test1 = Split<"foo,bar,baz", ",">
// type Test1 = ["foo", "bar,baz"]
["foo,bar", "baz"] とはならないようです。
先程の結果と同じように、左から処理をしていき、最短マッチとなるように推論していることが分かります。
Sep が先頭にある場合
type Test2 = Split<",fuga", ",">
// type Test2 = ["", "fuga"]
never とはならないようです。
先程の考察から考えると、どちらの infer も連続する最後の infer と捉えることができるので、空文字も当てはまるのだと推測ができます。
Sep が空文字の場合
type Test3 = Split<"hoge", "">
// type Test3 = ["h", "oge"]
["", "hoge"] ではないことに注意です。
少し不思議な結果に見えます。
一つ前の考察から考えると、どちらの infer も0文字以上のマッチであるので ["", "hoge"] となるように思います。
しかし、実際にはそうなっていません。
これは infer となっている部分以外が先に処理されると考えることで理解ができます。
上記の例の場合、
${infer L}${Sep}${infer R}-
${infer L}${infer R}-
{$Sep}の部分が先に処理され、空文字に置換された
-
のようになります。
したがって、連続する infer と判断されるため ${infer L} の部分は1文字以上の最短マッチとなっていると推測できます。
正規表現による解釈とまとめ
今までの例から正規表現で再解釈してまとめてみます。
| TypeScript | 正規表現 |
|---|---|
${infer T} |
/^(.*?)$/ |
${infer Y}-${infer M}-${infer D} |
/^(.*?)-(.*?)-(.*?)$/ |
${infer T}${infer U}${infer V} |
/^(.+?)(.+?)(.*?)$/ |
${infer L}${Sep}${infer R} |
RegExp("^(.*?)" + Sep + "(.*?)$") (Sep は空文字以外) |
${infer L}${Sep}${infer R} |
/^(.+?)(.*?)$/ (Sep は空文字) |
これらの結果より、
-
inferでない部分は先に文字列として処理 - (1つ以上の)連続する
inferの最後は、0文字以上のマッチ - (2つ以上の)連続する
inferの最後以外は、1文字以上の最短マッチ
ということだけを覚えておけば良さそうです。
おわりに
この記事は Type Challenges に取り組んでいるときに Sep が空文字の場合 と似た処理で悩んでいた時に実験したことをまとめたものです。
それゆえに、正確性に欠ける部分があると思います。
間違った部分を見つけた場合は、教えていただけると幸いです。
気になる方は TypeScript のコードを読み解いてみるといいかもしれません。