はじめに
TypeScript の型は世界一自由度の高い設計になっていると思います。
その根拠として、型を関数型のように書けるというのが大きいと思います。
type TrimS extends string> =
S extends ` ${infer R}` | `${infer R} ` ? TrimR> : S;
例えば、これは文字列の両端から空白を取り除く、str.trim()
相当のジェネリクスを含んだ型です。 (ジェネリクスは、型で用いられる引数のような存在だと思って貰えれば大丈夫です。)
実際に見てみましょう。
TypeScript Playground
(この記事では、TypeScript v5.8 を使用します。)
しっかり、str.trim()
相当の処理が型のみで推論できていることが分かります。
こんな事をここまで簡単に出来る言語は、TypeScript ぐらいではないでしょうか?
勿論自由に出来るということは厳しく出来るということです。
ただ、「何となく言いたいことは分かったけど、この型がどういう動作してこうなってるのかなんも分からん。」という方も居ると思います。
なので、この Trim 型が何を行っているかを実際に説明していきます。
Trim 型の中で何が起きているのか?
さて、先ほど紹介した Trim
型ですが、内部では以下のような4つの要素が組み合わさって動いています。
- 再帰
- 条件分岐
- 文字列テンプレートのパターンマッチ
- 推論(
infer
)による構造の抽出
めっちゃ関数型ですよね。
それぞれ解説していきます。
1. 再帰
type TrimS extends string> =
S extends ` ${infer R}` | `${infer R} ` ? TrimR> : S;
この型定義では、再帰が使われています。つまり Trim
という形で、自分自身を呼び出して処理を繰り返しているのです。
この再帰は「空白がなくなるまで」行われます。
たとえば " hello "
に対しては、以下のように再帰的に処理されます:
Trim" hello ">
=> Trim"hello ">
=> Trim"hello">
=> "hello"
( 注意: 実際には、Union型を使用しているので少し違う動作になりますが、そこは気にしなくて大丈夫です。 )
TypeScript は 型の深さ制限(デフォルトで1000) があるので、再帰も一定範囲なら安全です。(古いバージョンだと、型の深さ制限が50になっている物もあります。)
2. 条件分岐
次に S extends ... ? ... : ...
という構文に注目しましょう。
これは TypeScript の型における 条件分岐(Conditional Types)です。
記法的には、三項演算子に近いです。
S extends ` ${infer R}` | `${infer R} ` ? TrimR> : S;
ここでは「S
が前または後ろにスペースを持っている場合」には Trim
を実行し、そうでなければそのまま S
を返す、という分岐処理をしています。
言い換えると:
- 前か後ろにスペースがあれば → 再帰して削る
- スペースが無くなったら → 終了してそのまま返す
ただ、ここを上手く飲み込むためには次の文字列テンプレートのパターンマッチの理解が必要です。
3. 文字列テンプレートのパターンマッチ
TypeScript では、型レベルでも テンプレートリテラルのように文字列を扱うことができます。
これは「S
が ' '
+ 任意の文字列 R
」にマッチするか?という条件です。
同様に:
は「S
が任意の文字列 R
+ ' '
」か?を判定しています。
これらを組み合わせて、前後のスペースを削る処理を型で実現しています。
4. 推論(infer
)による構造の抽出
最後に infer R
について。
S extends ` ${infer R}` ? ...
ここで infer R
は、「S
が ' '
+ 何か にマッチするなら、その ‘何か’ を R
として抽出する」ことを意味しています。
つまり:
Trim" hello">
=> " hello" extends ` ${infer R}` → R = "hello"
=> Trim"hello">
この infer
は、関数の戻り値や配列の中身などにも使えて非常に強力です。
Trim 型のまとめ
type TrimS extends string> =
S extends ` ${infer R}` | `${infer R} ` ? TrimR> : S;
入力: " hello "
↓
1. " hello " は " ${R}" にマッチ → R = "hello "
↓
2. 再帰: Trim
↓
3. "hello " は "${R} " にマッチ → R = "hello"
↓
4. 再帰: Trim
↓
7. マッチしない → return "hello"
このような関数型で良く出てくる操作が組み合わさってこの型は実現されている訳です。
TypeScript が如何に柔軟であるかこれで分かったと思います。
さて、これを使えば何か面白い事が出来ないでしょうか?
そうです、馬鹿みたいに型を厳格にできます。
実行する前から結果を知れます。
実用性があるかは別として、夢がありますよね。
ちなみに、Split 型の型定義と本体はこんな感じです。
TypeScript Playground
ジェネリクスで引数の文字列を受け取って、Split 型に渡しています。
type Split
S extends string,
D extends string
> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...SplitTail, D>]
: [S];
Conditional Types を使用して、S
を Head
と Tail
に、D
という区切り文字で分割しています。
そして、先頭を配列の最初に追加し、残りを再帰的に取得していきます。
当てはまる条件 (S
に D
に含まれているか) が満たされなくなったら、その時点での S
を返し、分割完了です。
このような考え方を応用すれば、arr.join(",")
相当の処理なども作れますし、もっと極めればコンパイラーも作れます。
実際に、Hono などの著名なフレームワークにもこの考え方が応用されています。
RPC部分や魔法のような型推論はすべて TypeScript の力です。
裏側: https://github.com/honojs/hono/blob/main/src/types.ts
自分も頑張ってコードを読み込んで、コントリビュートしてみました。
Hono のコードを読めば、大方理解することには必ず TypeScript の知識が付いてるのでおすすめです笑
さいごに
ちなみに、TypeScript の型で数独を作ってる人も居たりします。
面白いですよね~
もしこの記事が面白かったら、いいね・拡散お願いします!!
Views: 0