Template Literal Types と infer の挙動について

## はじめに

この記事は TypeScript の [Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) と [`infer` キーワード](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#inferring-within-conditional-types)を一緒に用いたときの挙動について実験したものをまとめた記事です。

Template Literal Types や Conditional Types については知っているものとし、この記事では説明は行いません。

結論だけを見たい場合は、[以下を参照](#正規表現による解釈とまとめ)してください。

## Template Literal Types と `infer` の組み合わせ

Template Literal Types と `infer` キーワードを組み合わせることで、以下のようにマッチした文字列を取り出して利用することができます。

```ts
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` のみで構成された型を考えてみます。

```ts
type Separate<S extends string> =
    S extends `${infer T}${infer U}${infer V}`
        ? [T, U, V]
        : never
```

これは、与えられた文字列型を3つに分割する型です。
それぞれの `infer` にどんな文字が対応するのかを見ていきます。

#### ある程度の長さの文字列の場合

```ts
type Test1 = Separate<"Lorem ipsum">
// type Test1 = ["L", "o", "rem ipsum"]
```

基本は一文字で、最後の部分に残りの文字列が押し込まれていることから、左から処理を行って、1文字以上の最短マッチとなっていることが分かります。

#### 短い文字列の場合

```ts
type Test2 = Separate<"AB">
// type Test2 = ["A", "B", ""]
```

`["", "A", "B"]` とはならないようです。
最後の部分が空文字になっています。
先程の推測通りと考えるなら、残りの文字(空文字)が最後に押し込まれている、と捉えられそうです。

#### もっと短い文字列の場合

```ts
type Test3 = Separate<"A">
// type Test3 = never
```

与えられた文字列型は条件にマッチしませんでした。
`["A", "", ""]` とはならないようです。
最後以外は1文字以上でなければならない、と推測できます。


上記の結果から `infer` が連続するとき、最後の部分以外は1文字以上の最短マッチで、最後の部分は0文字以上のマッチとなっていると、推測できます。

### `infer` で Template Literal Types を挟む場合について

次に、以下の型を考えてみます。

```ts
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` を含む文字列の場合

```ts
type Test1 = Split<"foo,bar,baz", ",">
// type Test1 = ["foo", "bar,baz"]
```

`["foo,bar", "baz"]` とはならないようです。
[先程](#連続する-infer-について)の結果と同じように、左から処理をしていき、最短マッチとなるように推論していることが分かります。

#### `Sep` が先頭にある場合

```ts
type Test2 = Split<",fuga", ",">
// type Test2 = ["", "fuga"]
```

`never` とはならないようです。
[先程](#連続する-infer-について)の考察から考えると、どちらの `infer` も連続する最後の `infer` と捉えることができるので、空文字も当てはまるのだと推測ができます。

#### `Sep` が空文字の場合

```ts
type Test3 = Split<"hoge", "">
// type Test3 = ["h", "oge"]
```

`["", "hoge"]` ではないことに注意です。
少し不思議な結果に見えます。
[一つ前](#sep-が先頭にある場合)の考察から考えると、どちらの `infer` も0文字以上のマッチであるので `["", "hoge"]` となるように思います。
しかし、実際にはそうなっていません。

これは `infer` となっている部分以外が先に処理されると考えることで理解ができます。

上記の例の場合、

1. `${infer L}${Sep}${infer R}`
2. `${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` は空文字) |

これらの結果より、

1. `infer` でない部分は先に文字列として処理
2. (1つ以上の)連続する `infer` の最後は、0文字以上のマッチ
3. (2つ以上の)連続する `infer` の最後以外は、1文字以上の最短マッチ

ということだけを覚えておけば良さそうです。

## おわりに

この記事は Type Challenges に取り組んでいるときに [`Sep` が空文字の場合](#sep-が空文字の場合) と似た処理で悩んでいた時に実験したことをまとめたものです。
それゆえに、正確性に欠ける部分があると思います。
間違った部分を見つけた場合は、教えていただけると幸いです。
気になる方は [TypeScript のコード](https://github.com/microsoft/TypeScript)を読み解いてみるといいかもしれません。