ts-array-lengthを支えるテクニック
皆さんこんにちは。筆者は先日、TypeScript向けライブラリのts-array-lengthを公開しました。
この記事ではこのライブラリを宣伝するとともに、ライブラリの実装がどのようになっているのか解説します。
ts-array-lengthの機能
ts-array-lengthは3つの関数を提供しており、これらを使うことでなんと配列の要素数をチェックできます。
例えばhasLengthを使うと、配列の要素がちょうど2個かどうか調べることができます。
if (hasLength(arr, 2)) {
// arrは2要素の配列!
const [first, last] = arr;
}
他にも、要素数がある数値以上かどうか判定するhasMinLengthと、要素が1つでもあるかどうか判定するisNonEmptyがあります。
if (hasMinLength(arr, 3)) {
// arrは3要素以上
}
if (isNonEmpty(arr)) {
// arrは1要素以上
}
めちゃくちゃ画期的ですね。ts-array-lengthが火星に到達する日も遠くないでしょう。
ts-array-lengthがなぜ必要なのか
……というのは冗談ですが、こんな関数たちはなぜ必要なのでしょうか。それはもちろん、型チェックのためです。
とくに、これらの関数はTypeScriptのnoUncheckedIndexedAccessオプションが有効な場合に役に立ちます。このオプションが有効な状況では、配列に対してインデックスアクセスすると常にundefinedの可能性があるものとして扱われます。
// arr: string[]
const first = arr[0];
// ^? const first: string | undefined
そして、現在のところTypeScriptはarr.lengthに対してif文などでチェックしても、その結果を考慮してくれません。
if (arr.length > 0) {
// まだ string | undefined になってしまう……
const first = arr[0];
}
ということで、チェックすれば型が絞り込まれるように作ったのがts-array-lengthです。例えばhasLengthの場合、次のような絞り込みが提供されます。
// ここではarrは string[] 型
if (hasLength(arr, 2)) {
// ここではarrは readonly [string, string] 型
}
TypeScriptでユーザー定義の絞り込みを行うためには関数呼び出しを介する必要があるので、このように関数の形で提供しています。
絞り込まれたif文の中でarrに破壊的変更をしたら壊れてしまうとかそういう問題は無くはないのですが、それは大抵の絞り込みで言えることなので今回は目を瞑ります。🙈
ts-array-lengthはいつ役立つのか
前述のように、ts-array-lengthはTypeScriptのnoUncheckedIndexedAccessオプションが有効な場合に役に立ちます。このオプションはstrictをtrueにしてもデフォルトではオンにならないという厳しいオプションですが、これをオンにしないと型に現れないundefinedが出現するため、ぜひオンにすべきです。
常にundefinedの可能性が付きまとうのは不便に思いますが、まずはそもそもインデックスアクセスを使うのをなるべく控えるべきです。代わりに、for-of文やforEach、mapといった方法を使えばundefinedは出てきません。しかし、それでもインデックスアクセスが必要になってしまう場面は存在します。
また、もともとnoUncheckedIndexedAccessが有効ではなかったコードベースに後からnoUncheckedIndexedAccessを有効にする場合は、インデックスアクセスに依存したコードが多くなってしまっていることがあります。その場合に最低限の修正で型エラーを消すためにもts-array-lengthが有効です。
ts-array-lengthの裏側
ここからは、ts-array-lengthの実装がどのようになっているのか説明します。前述のように、型の絞り込みのためには関数が必要であり、関数の返り値を 引数名 is 型という形(型述語)にすることで、その関数がtrueを返したら引数名が型に絞り込まれるという意味になります。
具体的には、hasLength, hasMinLength, isNonEmpty は次のように実装されています。
export function hasLength<T, N extends number>(
arr: readonly T[],
length: N,
): arr is ReadonlyArrayExactLength<T, N> {
return arr.length === length;
}
export function hasMinLength<T, N extends number>(
arr: readonly T[],
length: N,
): arr is ReadonlyArrayMinLength<T, N> {
return arr.length >= length;
}
export function isNonEmpty<T, N extends number>(
arr: readonly T[],
): arr is ReadonlyArrayMinLength<T, 1> {
return arr.length >= 1;
}
絞り込み先の型として使われているReadonlyArrayExactLengthとReadonlyArrayMinLengthがこのライブラリの本体です。ReadonlyArrayExactLength<T, N>は、要素数がちょうどN個あるT型の配列です。ReadonlyArrayMinLength<T, N>は、要素数がN個以上あるT型の配列です。このような型はTypeScriptのタプル型を用いることで実装できます。
指定した要素数のタプル型を作る
このような型を実装するための基本的なアイデアは次の記事で説明されていますが、改めて簡単に説明します。
例えば、要素数がちょうど2個のタプル型は [T, T]型、2個以上のタプル型は[T, T, ...T[]]と表現できます。
そこで、ReadonlyArrayExactLength<T, 2>のように、要素数を数値のリテラル型で与えれば上記の型が生成されるような型を実装することを考えます。
実装は、TypeScriptでは「タプル型に関して要素を1個追加した新しいタプル型を作れる」ということと、「タプル型の要素数はlengthプロパティの型を見ればリテラル型で取得できる」ことを利用して、型を再帰することで行えます。基本的な実装は次の通りになります。
export type ReadonlyArrayExactLength<T, N extends number> =
ReadonlyArrayExactLengthRec<T, N, readonly []>;
type ReadonlyArrayExactLengthRec<
T,
L extends number,
Result extends readonly T[],
> = Result["length"] extends L
? Result
: ReadonlyArrayExactLengthRec<T, L, readonly [T, ...Result]>;
ReadonlyArrayExactLengthRecは、Resultに与えられたタプル型がL個の要素を持っているかどうか判定し、違う場合はResultに1個付け足して再帰します。Resultの要素がL個になったら再帰を終了してResultを返します。
計算はおおよそ次のように行われます。
ReadonlyArrayExactLength<T, 2>
= ReadonlyArrayExactLengthRec<T, 2, readonly []>
= ReadonlyArrayExactLengthRec<T, 2, readonly [T]>
= ReadonlyArrayExactLengthRec<T, 2, readonly [T, T]>
= readonly [T, T]
詳細は省略しますが、ReadonlyArrayMinLengthのほうも同様の実装となっています。
いろいろな使い方に耐えられるように改良する
以上が基本的なアイデアですが、ts-array-lengthに実装されているものはもう少し複雑な定義となっています。その理由は、変な使い方をされても大丈夫なようにです。
ユニオン型の対応
まず、Nがユニオン型だった場合を考えましょう。
ReadonlyArrayExactLength<string, 1 | 2 | 3>
この場合、「1要素か2要素か3要素」という意味なので[string] | [string, string] | [string, string, string]となるのを期待したいところですが、上の実装だとそうなりません。実際には[string]となってしまいます。その理由は、ResultのlengthがNに到達したか判断するところでNが1 | 2 | 3なので、「lengthが1または2または3なら再帰を終了する」という意味になってしまうからです。
ReadonlyArrayExactLength<T, 1 | 2 | 3>
= ReadonlyArrayExactLengthRec<T, 1 | 2 | 3, readonly []>
= ReadonlyArrayExactLengthRec<T, 1 | 2 | 3, readonly [T]>
= readonly [T]
この挙動を改善するために有効なのがunion distributionです。これはユニオン型の各要素に対して別々に計算して、その結果をユニオン型にまとめることができる機構です。具体的には、次のように実装を修正します(ReadonlyArrayExactLengthRecの実装は同じなので省略)。
export type ReadonlyArrayExactLength<T, N extends number> =
N extends number
? ReadonlyArrayExactLengthRec<T, N, readonly []>
: never;
実装としては、N extends numberという条件分岐(条件型; conditional type)が入りました。実は、そもそも型引数の制約にN extends numberと書いてあるので、条件分岐としては常にtrueになり意味がありません。
TypeScriptでは、このようにconditional typeの条件部分の形が「型引数 extends 型」である場合はunion distributionが起こります。その場合、型引数がユニオン型であれば、ユニオン型のそれぞれの構成要素に対して別々に結果が計算され、それらがユニオン型でまとめられるのです。今回の場合、Nが1 | 2 | 3であれば、Nが1の場合と2の場合と3の場合にばらして型の計算が行われることになります。
つまり、新しい実装ではReadonlyArrayExactLength<T, 1 | 2 | 3>は次のように計算されます。
ReadonlyArrayExactLength<T, 1 | 2 | 3>
= ReadonlyArrayExactLengthRec<T, 1, readonly []> | ReadonlyArrayExactLengthRec<T, 2, readonly []> | ReadonlyArrayExactLengthRec<T, 3, readonly []>
これで意図した挙動にすることができました。このように、TypeScriptではunion distributionを使うことでユニオン型をかなり柔軟に取り扱うことができ、この記事のような複雑な型を実装する場合に役立ちます。
Nにnumberが入ってきたときの対応
今回実装した型は、Nに具体的な数が入ってきた場合に意味を成します。そのため、ReadonlyArrayExactLength<T, number>のような指定にはあまり意味がありません。しかし、汎用性の高い型とするためにはこのような場面にも対応しなければいけません。
ReadonlyArrayExactLength<T, number>という型は、lengthがnumberなら良いという意味なので、実質何も制限をかけていないと解釈するのが妥当そうです。つまり、この場合はreadonly T[]を結果とすれば良さそうですね。このケースのための分岐を入れる必要があるので、次のようにします。
export type ReadonlyArrayExactLength<T, N extends number> =
number extends N
? readonly T[]
: N extends number
? ReadonlyArrayExactLengthRec<T, N, readonly []>
: never;
追加されたのは最初の「number extends N」という分岐です。これは「numberがNの部分型である」という意味なので、Nはnumber全体を受け入れられる型でないといけません。今回の場合はN extends numberという制約もかかっているので、これでNがnumberであるという条件になっています。
Nが非負整数ではないときの対応
これまで、Nには非負整数が入るという前提で話していましたが、実際には-3とか0.5といった値がNに与えられる可能性もあります。この場合に対処しなければいけません。
今の実装では、次のようなエラーが出ます。これは上限までループしても型の計算が終わらなかったという意味です。今回の場合、型の計算が無限ループに入ってしまうためこのエラーが出ます。
// エラー: Type instantiation is excessively deep and possibly infinite.(2589)
type A = ReadonlyArrayExactLength<string, -3>
ReadonlyArrayExactLengthRecの計算では最初にResultのlengthが0から始まり、1、2、3、……と進んでNに一致するまでResultの要素数を増やし続けます。Nが-3や0.5だった場合はResultのlengthがずっと一致しないため無限ループになってしまいます。
配列のlengthが-3や0.5などになることはないので、非負整数以外の数値型がNとして与えられたときはReadonlyArrayExactLengthRecの計算をしないようにする必要があります。そのような値が存在しないという意味でneverにしてもよいですが、何となく怖いのでこちらも余計な制限をかけないreadonly T[]を結果にしておきましょう。
そうなると、Nという数値のリテラル型が、非負整数かどうかを判定する必要があります。これをIsCertainlyInteger<N>として実装してみましょう。ただ、TypeScriptは型レベルの数値計算をサポートしていないので、これは一筋縄ではいかず、完璧な判定はできません。しかし、テンプレートリテラル型を使えばある程度の判定は可能です。それが次の実装です。
export type IsCertainlyInteger<N extends number> =
IsCertainlyIntegerImpl<`${N}`>;
type IsCertainlyIntegerImpl<Str extends string> =
Str extends `${infer _}.${infer _}`
? false
: Str extends `-${infer _}`
? false
: Str extends "Infinity" | "-Infinity" | "NaN"
? false
: true;
この実装では、IsCertainlyIntegerは、与えられたNを文字列のリテラル型`${N}`に変換してIsCertainlyIntegerImplに渡しています。TypeScriptは文字列に対する型レベル計算はできるので、文字列にして判定しようという魂胆です。例えばNが3というリテラル型だった場合、文字列型への変換後は"3"という型になります。同様に、-3であれば"-3"に、0.5であれば"0.5"になります。
それを踏まえて上の実装を見ると、「文字列に変換した後、.を含んでいる文字列や-で始まる文字列、InfinitelyやNaNは弾く」というものになっています。これで負の数や小数は弾けます。ただ、1.23e+100のような大きな値を間違えて弾いてしまうことがあります。今回は、そのような大きな数値のユースケースがないので問題ありません。
上の型関数を組み込んだReadonlyArrayExactLengthは次のようになります。これで、負の数や小数がNとして与えられても無限ループにはなりません。
export type ReadonlyArrayExactLength<T, N extends number> =
number extends N
? readonly T[]
: N extends number
? IsCertainlyInteger<N> extends true
? ReadonlyArrayExactLengthRec<T, N, readonly []>
: readonly T[]
: never;
ライブラリとして便利な型関数を提供する場合、このようになるべく色々な入力に耐えられるようにするとよいでしょう。
実装の欠点
実際のts-array-lengthの実装は上記の工夫を全部盛り込んでいますが、一応欠点はあります。例えば、ReadonlyArrayExactLength<string, 1000>のように大きな整数をNとして与えると再帰の上限に達してエラーとなってしまいます。タプル型の生成の効率を上げれば上限を増やせそうですが、そこまで大きな数のサポートはあまり必要なさそうなので今回は省いています。
まとめ
今回は、筆者がとある事情で作ったts-array-lengthを紹介し、その実装について詳しく説明しました。このような型レベルの実装は体系的な説明がしにくいのですが、具体例を通じてみなさんの参考になれば幸いです。
Discussion