TypeScriptでは配列の型はT[]
のように宣言します(Array<T>
でも可)。この配列は、もちろん要素数が何個でも構いません。
const arr1: number[] = [0, 1, 2];
const arr2: number[] = [0];
const arr3: number[] = [];
しかし時折、「2個以上の要素を持った配列」のような条件を書きたくなることがあるかもしれません。すなわち、配列の最低要素数を型で指定したいという場合ですね。実はTypeScriptでは、タプル型を応用することでこれが可能です。
タプル型を用いた最低要素数の表現
// 要素が最低2個あるT型の配列
type AtLeast2<T> = [T, T, ...T[]];
const arr1: AtLeast2<number> = [0, 1, 2]; // これはOK
const arr2: AtLeast2<number> = [0, 1]; // これもOK
const arr3: AtLeast2<number> = [0]; // これはエラー
ここで宣言されているAtLeast2<T>
型の中身は[T, T, ...T[]]
という型です。このように[]
の中に型を書き連ねたものをタプル型といいます(詳しくは筆者の過去記事TypeScriptの型入門をご参照ください)。これの意味を簡単にいうと、「最初の要素はT
、次の要素もT
、そして残りの部分がT[]
に当てはまるような配列の型」です。宣言の時点で「最初の要素」と「次の要素」の存在が確定しているため、これは最低2個の要素を持つT
型の配列という意味になるのです。
なお、要素の型は全部T
に統一する必要はありません。例えば[string, string, ...number[]]
だと、「最初の2つの要素が文字列で残りの要素は数値」という条件の配列の型を宣言できます。
一般化
さて、ここからが本題です。上の例では最低要素数は「2個」と決まっていました。他にも1個とか3個、5個の場合が欲しいときはどうすればいいでしょうか。一つの手はこうすることです。
type AtLeast1<T> = [T, ...T[]];
type AtLeast3<T> = [T, T, T, ...T[]];
type AtLeast5<T> = [T, T, T, T, T, ...T[]];
しかしこれでは美しくないですね。理想的なのは、AtLeast<N, T>
という型をひとつだけ定義してAtLeast<1, T>
とかAtLeast<3, T>
やAtLeast<5, T>
のように使うことです。
そこで、この記事ではこれを実現する方法を紹介します。
結論
結論からいうとこうすればできます。
type Append<Elm, T extends unknown[]> = ((
arg: Elm,
...rest: T
) => void) extends ((...args: infer T2) => void)
? T2
: never;
type AtLeast<N extends number, T> = AtLeastRec<N, T, T[], []>;
type AtLeastRec<Num, Elm, T extends unknown[], C extends unknown[]> = {
0: T;
1: AtLeastRec<Num, Elm, Append<Elm, T>, Append<unknown, C>>;
}[C extends { length: Num } ? 0 : 1];
// ---------- 使用例 ----------
const arr: AtLeast<3, number> = [0, 1]; // これはエラー
const arr2: AtLeast<3, number> = [0, 1, 2]; // これはOK
const arr3: AtLeast<5, number> = [0, 1, 2]; // これはエラー
const arr4: AtLeast<5, number> = [0, 1, 2, 3, 4]; // これならOK
使用例を見ると、やりたいことがちゃんと実現できていることが分かります。また、例えばarr2
の型を調べると[number, number, number, ...number[]]
となっており、先ほどと原理は同じであることが分かります。
ちなみに、AtLeast
の型引数に3
や5
といった数値を渡しているように見えますが、これは数値のリテラル型です。詳しくは前述の「型入門」を読んでほしいのですが、例えば3
型は3
という数値のみが許される型です。
解説
では上記の例を解説していきます。まず、AtLeast<3, number>
がどのように評価されるのかを確かめます。
定義に従って、AtLeast<3, number>
はAtLeastRec<3, number, number[], []>
になります。
詳細はあとで戻ってきて説明しますが、AtLeastRec
の定義に従ってこれを展開するとAtLeastRec<3, number, [number, ...number[]], [unknown]>
になります。
再びAtLeastRec
の定義に従って展開するとAtLeastRec<3, number, [number, number, ...number[]], [unknown, unknown]>
になります。
さらにAtLeastRec
の定義に従って展開するとAtLeastRec<3, number, [number, number, number, ...number[]], [unknown, unknown, unknown]>
になります。
そして、これをAtLeastRec
の定義に従って展開すると[number, number, number, ...number[]]
になります。
このように、AtLeastRec<Num, Elm, T, C>
は型レベル再帰を行いながら第三引数T
を育てていきます。T
は最初Elm[]
、つまり要素数0個以上の配列です。1回再帰するごとにT
の最初に要素Elm
が追加され、T
の最低要素数が1つ増えます。よって、Num
回再帰してからT
を返すことによって、最低要素数Num
のT
の配列を作っているのです。
この再帰を制御するには、「今は何回目の再帰か」という情報を保持する必要があります。さっき何気なく登場した数値のリテラル型を使えばいけるように思えますが、それはできません。なぜなら、数値のリテラル型は数値計算をサポートしていないからです。「3
というリテラル型に1を足して4
というリテラル型にする」というような型レベル計算はできません。
そこで、今回はタプル型を用いて自然数を表しています。それがAtLeastRec
の第4型引数C
の役割です。実は、TypeScriptではタプル型の要素数をlength
プロパティを通じて数値のリテラル型の形で得ることができます。つまり、[]
(0要素のタプル型)のlength
プロパティは0
型だし、[unknown, unknown]
(2要素のタプル型)のlength
プロパティは2
型を持っています。
先ほどの再帰でAtLeastRec
の第4型引数C
に注目すると、最初は[]
となっています。これは再帰が0回目である(最初の再帰である)ことを表しています。次の再帰ではC
は[unknown]
です。またその次では[unknown, unknown]
となっています。このように1回の再帰につきC
の先頭にunknown
を足すことによって、C
を再帰の回数のカウンタとして用いているのです。AtLeastRec
はC
の要素数を見てそれがNum
になっていたら、Num
回再帰したと見なして再帰を終了し結果を返します。
素朴な実装
以上で解説したアイデアを素朴に書き下してみると、こんな感じになるはずです。
type AtLeast<N extends number, T> = AtLeastRec<N, T, T[], []>;
type AtLeastRec<Num, Elm, T extends unknown[], C extends unknown[]> =
C extends { length: Num }
? T
: AtLeastRec<Num, Elm, [Elm, ...T], [unknown, ...C]>;
AtLeastRec
は、今がNum
回目の再帰かどうかを条件型 (conditional type) を用いて判定します。C extends { length: Num }
という条件はC
のlength
プロパティがNum
であるという条件を表しており、C
がタプル型であるという前提を踏まえるとC
の要素数がちょうどNum
個であるという意味になります。
その場合は、これまで育ててきたT
を結果として返します。そうでなければT
の先頭にElm
を足したタプル型[Elm, ...T[]]
を作り新たなT
とします。また、C
にもunknown
をひとつ足して[unknown, ...C[]]
とします。
お分かりの通り、C
に足すunknown
は特に意味はなく、なんでも構いません。
しかし、上の素朴な実装には2つの問題があります。ひとつは[Elm, ...T]
とは書けない(タプル型の末尾の...
で型引数を展開することはできない)という点、そしてもうひとつはTypeScriptでは型の再帰を直接書けないという点です。
ここからは、この2つの問題点を回避する方法を説明します。
TypeScriptでタプル型の先頭に要素を追加する方法
TypeScriptでは、たとえT
が配列型であることが明らかでも[Elm, ...T]
と書くことは許可されていません(この制約がなぜ存在するのかはよく分かりません。実装をサボっているだけかもしれませんが)。
この制約は、関数型を経由することで回避できます。TypeScriptではタプル型と(可変長引数)関数の型は相互変換可能であり、しかも関数の側では「先頭に要素を追加する」という操作が可能なのです。
タプル型から関数型への変換は簡単です。可変長引数の構文を用いてタプル型を展開します。次の例が分かりやすいでしょう。...args
のところにタプル型を付けることで、タプル型が引数の型に展開されています。
type T1 = [number, string, ...number[]];
// F1 は (args_0: number, args_1: string, ...args_2: number[]) => void という型になる
type F1 = (...args: T1) => void;
逆に、関数型からタプル型への変換は条件型を使います。
type F2 = (arg0: number, arg1: string, ...args: number[]) => void;
// T2 は [number, string, ...number[]] 型
type T2 = F2 extends (...args: infer T) => void ? T : never;
この例では条件型を型レベルパターンマッチのように使用しています。F2
という関数の型を(...args: infer T) => void
にマッチさせることで、型引数T
に引数の型一覧をタプル型の形で入手しているのです。条件型におけるinfer
キーワードは、このように型引数を導入してパターンマッチの結果を取り出すのに利用できます。
この場合F2
は必ずマッチするので、マッチしなかった場合の返り値の型never
に特に意味はありません。
このようにしてタプル型と関数型の相互変換ができました。そして、実は関数型では可変長引数より前に引数を追加することが可能なのです。
type T3 = [number, string, ...number[]];
// F3 は (firstArg: boolean, args_0: number, args_1: string, ...args_2: number[]) => void という型になる
type F3 = (firstArg: boolean, ...args: T3) => void;
この例では、タプル型から関数型に変換するときに引数を先頭にひとつ追加しています。[boolean, ...T3]
という型はエラーにされてしまいましたが、関数型では同じことが許されるのです。
ということで、タプル型の先頭に要素を追加したい場合は一旦関数型にして追加してからタプル型に戻せばいいことが分かりました。この部分を型関数として切り出すとこうなります。
type Append<Elm, T extends unknown[]> = ((
arg: Elm,
...rest: T
) => void) extends ((...args: infer T2) => void)
? T2
: never;
// ---------- 使用例 ----------
// T1 は [number, string, string] 型
type T1 = Append<number, [string, string]>;
// T2 は [number, string, ...unknown[]] 型
type T2 = Append<number, [string, ...unknown[]]>;
早速これを使ってAtLeastRec
を書き換えましょう。
type Append<Elm, T extends unknown[]> = ((
arg: Elm,
...rest: T
) => void) extends ((...args: infer T2) => void)
? T2
: never;
type AtLeast<N extends number, T> = AtLeastRec<N, T, T[], []>;
type AtLeastRec<Num, Elm, T extends unknown[], C extends unknown[]> =
C extends { length: Num }
? T
: AtLeastRec<Num, Elm, Append<Elm, T>, Append<unknown, C>>;
これでエラーがひとつ解消できました。
TypeScriptで型の再帰を書く方法
もうひとつの問題はTypeScriptでは型の再帰を直接書けないという点でした。上の例ではAtLeastRec
が再帰になっていることがTypeScriptコンパイラにバレてしまい、怒られてしまいます。
そのため、型の再帰を書くにはひと工夫する必要があります。いろいろな手法がありますが、簡単なのは再帰部分をオブジェクト型に包み、さらに条件型を用いて遅延評価しながら取り出すというものです。
何を言っているのか分からないと思いますので、先に結論を見ましょう。AtLeastRec
をこう書き換えます。
type AtLeastRec<Num, Elm, T extends unknown[], C extends unknown[]> = {
0: T;
1: AtLeastRec<Num, Elm, Append<Elm, T>, Append<unknown, C>>;
}[C extends { length: Num } ? 0 : 1];
まず、再帰部分AtLeastRec<Num, Elm, Append<Elm, T>, Append<unknown, C>>
が{ 0: T; 1: /* 省略 */ }
という形でオブジェクト型の中に入りました。これがオブジェクト型で包むということです。
このようにオブジェクト型で包むことによって、型が再帰していても弾かれなくなります。ここで、このオブジェクト型は0
と1
という2つのプロパティを持っています。0
はC extends { length: Num }
が満たされたときに返したい型、1
はそうでないときに返したい型が入っています。
とはいえ、オブジェクト型から(プロパティアクセス型T[K]
を用いて)中身の型を取り出すときに{ /* 省略 */ }[1]
のようにやってしまうと、オブジェクト型が解決されてしまうため結局直接再帰している扱いとなり、やはり同様のエラーとなります。
ということで、ここでもうひとつのポイントである条件型を用いて遅延評価しながら取り出すということを行っています。具体的には、型を取り出すときのキー(プロパティ名)をC extends { length: Num} ? 0 : 1
という条件型で決定しています。
条件型には遅延評価されるという性質があるため、この型の宣言を見ただけでは0
と1
のどちらが返されるのか分かりません。これにより、宣言の段階でプロパティアクセス型が解決されるのを回避していま。
これも何だかTypeScriptの推論器の穴を突いている気がして、そのうち塞がれそうでちょっと怖いですね。しかしとにかくこれでエラーを回避しながら目的を達成できました。
まとめ
この記事では、タプル型を用いて最低要素数が決まっている配列の型を宣言するという手法を紹介し、さらにその最低要素数を数値リテラル型を用いて指定できるように一般化する方法を紹介しました。
ポイントは型レベル再帰の扱い方やタプル型と配列型の相互変換といったテクニック、そしてタプル型をカウンタとして扱うというアイデアです。知識とアイデアがあればこれくらいのことができるというTypeScriptの面白さを知っていただけると幸いです。
ちなみに、型レベル再帰の回数には制限があるので注意してください。今回の場合だと、AtLeast<46, number>
のように要素数を46以上にすると再帰の限界を超えてエラーになりました。
謝辞
この記事は以下のツイートから着想を得ました。この場を借りてお礼申し上げます。
TypeScript で配列の要素数を制限する方法があるの初めて知った.例えばこれは要素数2以上の配列:
— ドッグ (@Linda_pp) 2019年6月2日
type A<T> = [T, T, ...T[]]
const a1: A<number> = [1, 2, 3]; // OK
const a2: A<number> = [1, 2]; // OK
const a3: A<number> = [1]; // ERROR
それみると、 A<number, _10> みたいに、要素数をリテラルっぽく書けるといいなって気がしました。ただ、TypeScriptの型レベル計算だと難しいですかね…。
— Kota Mizushima (@kmizu) 2019年6月4日