小ネタです。
元ネタ
typescript - Is it possible to restrict number to a certain range - Stack Overflow
Range型とは
普段数字を扱うのにnumber型を使いますが、更に制約の厳しい、指定した最小~最大の値の範囲内であることを型レベルで保障してくれる型が欲しくなりました。
一昔前、検索してみたところStackOverFlowに
type OneToTen = 1|2|3|4|5|6|7|8|9|10
こんな感じでベタ書きゴリ押しで定義するやり方が乗っていて爆笑しました。ただ全部手書きは明らかに面倒だし、数が多い場合に限界があるのでどうにかならないのかなと感じました。
あれから時は経ち……
回答例(TypeScript ver4.5以降)
解説しづらいので型引数の名前を改名してあります。
type Enumerate<Max extends number, IncrementalNumbers extends number[] = []>
= IncrementalNumbers['length'] extends Max
? IncrementalNumbers[number]
: Enumerate<Max, [...IncrementalNumbers, IncrementalNumbers['length']]>
type IntRange<Min extends number, Max extends number> =
Exclude<Enumerate<Max>, Enumerate<Min>> | Max
type TwentyToThreeHundred = IntRange<20, 300>
コピペしてみたところ、IntRange型に渡した数字の区間のUnionが返ってきました。自動でUnionを作ってくれるのなら魅力的なので解読してみることにしてみました。
本来粒度の小さいEnumerate
型から解読すべきとは思うのですが、これに関しては逆に大元から読んだ方がいいと思ったので最下段のTwentyToThreeHundred
型から読んでいきます。
TwentyToThreeHundred型
type TwentyToThreeHundred = IntRange<20, 300>
これはIntRange
型にジェネリクスで2つの型引数を渡しています。おそらく最小値と最大値ですね。
IntRange型
type IntRange<Min extends number, Max extends number> =
Exclude<Enumerate<Max>, Enumerate<Min>> | Max
次はこちら。ジェネリクス内でextends
キーワードを使うことで、制限をかけたり、条件判定したりすることができます。今回はnumber
型の制限かけてるだけですね。
そしてここで注目してほしいのが、
Exclude<Enumerate<Max>, Enumerate<Min>> | Max
の部分。Exclude
型はUtility Typesの一種で、最初の型引数として渡したUnion型から後の型引数で渡したUnion型を除く型を作る型です。
Enumerate
型の実装をまだ見てないので推測ですが、「0~最大値までのUnion」から「0~最小値のUnion」を引いて目的のUnionを作っているようですね。
(2023/06/16追記)
元々Exclude云々しか載せてなかったのですが、このままだと「以上/未満」のUnionが出来上がるため、0~9のUnionを作るのにIntRange<0,10>
という形で定義する必要があります。
直感的に「以上/以下」にするにどうしたらいいのかと、元ネタのStackOverFlowを調べたところ、コメント欄の中に記載があり、素直にMaxを付け加えるのがいいとのことです。
Enumerate型
type Enumerate<Max extends number, IncrementalNumbers extends number[] = []>
= IncrementalNumbers['length'] extends Max
? IncrementalNumbers[number]
: Enumerate<Max, [...IncrementalNumbers, IncrementalNumbers['length']]>
出たよCondtional Types。何やってるかさっぱり分からん……。😨(クソ雑魚)というわけで当記事の最大の山場です。1行ずつ見ていきましょう。
型引数(1行目)
type Enumerate<Max extends number, IncrementalNumbers extends number[] = []>
第1型引数Max
は数字、そして第2型引数IncrementalNumbers
は数字配列で、デフォルト型引数は空配列として設定されているようですね。
先ほどIntRange
型を見たときはEnumerate
型に第2型引数を渡してなかったので、どうやらあえてデフォルト引数を用いる形で使うのがポイントになりそうです。
条件判定(2行目)
IncrementalNumbers['length'] extends Max
型の世界(型レベルプログラミング)においてはextends
と?
で条件判定して:
を使って分岐することができるので、ここは普通のプログラミングならばif構文の条件部分にあたる箇所になります。
まずIncrementalNumbers['length']
とか型の世界(型レベルプログラミング)なのにいきなりプロパティにアクセスしてて面食らいますが、これはインデックスアクセス型という型で、オブジェクトの特定のプロパティの型を参照するのに使うやつですね。
次に引っ掛かるのがArray
型のlength
プロパティってnumber
型じゃないの?という点ですが、実際にサンプルコードを書いて試してみましょう。
type T = [1,2,3]
type TLength = T['length']
TLength
型はnumber
型になるでしょうか。答えはWebで!これはたまげたなぁ……😮
……というわけで第2型引数の配列の長さが厳密に推論され取得できます。1行目でデフォルト引数を使っているのがポイントと書いた通り、初回は空配列=長さは0なので、0
型となります。
またMax
型は今回のサンプルだと20
(ないし300
)が渡ってくるので実際に行われている条件判定は以下のようになります。
// 初回
0 extends 20
0
型が20
型を継承してるかという判定を行っています。TS初心者だと一瞬面食らう質問かもしれませんが、ここは直感を信じてfalseが正解となります。
ここまで解読出来たら何となく先が読めてきたのではないでしょうか。
条件判定でfalseだった場合の処理(4行目)
3行目は飛ばして4行目から行きます。大半のループではこっちに入るので。
Enumerate<Max, [...IncrementalNumbers, IncrementalNumbers['length']]>
はい、型の世界でも再帰することができます。第1型引数は同じ値を渡し、第2型引数に今度は元の配列に長さの数字を加えたものを渡しています。
初回の場合はIncrementalNumbers
型は空配列なので、第2型引数には空配列ではなく[0]型
を渡すことになります。 初回の時と違ってますね?
ここがポイントで、lengthで配列の長さを取って追加することで、再帰の度にIncrementalNumbersの長さが伸びていき、疑似的なループ装置としています。
混乱する人もいると思うので具体例を出すと2回目(再帰1回目)はIncrementalNumbers型は[0]
型でlengthは1となり、3回目に向けて第2引数には[0,1]
型が渡されることになります。そして3回目のIncrementalNumbers型は[0,1]型
で、lengthは2……と続きます。
条件判定でtrueだった場合の処理(3行目)
というわけで延々とIncrementalNumbers
型を伸ばしていき、2行目の条件判定部分でMax
型を継承……実質イコールかどうかをチェックしているのですが、見事一致した場合はここで無限再帰から脱出します。
IncrementalNumbers[number]
いきなり[number]
というワードが出てきて驚く方もいらっしゃるかもしれませんが、これもインデックスアクセス型の機能の一つで、配列型の中の要素を列挙したUnion型として取得することができます。理屈ぽく言うと「配列に数字でアクセスした際に取得しうる値(→配列内の全要素のうちどれか→全要素のUnion)」を取得するって感じですかね~。
IncrementalNumbers型はこの際[0,1,2,3....]
という感じの型となっているので、条件判定と合わせて、めでたく「0~第1型引数Max
として指定した値までのUnion」を取得することができます。
感想
こんなんパッと見で分かるかー!型の道はかくも厳しい……⛰
ちなみに元ネタのツリーには更なる改良版も掲載されているっぽいので、気になる方はチェックしてください。