TypeScript

TypeScriptで最低n個の要素を持った配列の型を宣言する方法

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[]]となっており、先ほどと原理は同じであることが分かります。

Screenshot from Gyazo

ちなみに、AtLeastの型引数に35といった数値を渡しているように見えますが、これは数値のリテラル型です。詳しくは前述の「型入門」を読んでほしいのですが、例えば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を返すことによって、最低要素数NumTの配列を作っているのです。

この再帰を制御するには、「今は何回目の再帰か」という情報を保持する必要があります。さっき何気なく登場した数値のリテラル型を使えばいけるように思えますが、それはできません。なぜなら、数値のリテラル型は数値計算をサポートしていないからです。「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を再帰の回数のカウンタとして用いているのです。AtLeastRecCの要素数を見てそれが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 }という条件はClengthプロパティが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: /* 省略 */ }という形でオブジェクト型の中に入りました。これがオブジェクト型で包むということです。

このようにオブジェクト型で包むことによって、型が再帰していても弾かれなくなります。ここで、このオブジェクト型は01という2つのプロパティを持っています。0C extends { length: Num }が満たされたときに返したい型、1はそうでないときに返したい型が入っています。

とはいえ、オブジェクト型から(プロパティアクセス型T[K]を用いて)中身の型を取り出すときに{ /* 省略 */ }[1]のようにやってしまうと、オブジェクト型が解決されてしまうため結局直接再帰している扱いとなり、やはり同様のエラーとなります。

ということで、ここでもうひとつのポイントである条件型を用いて遅延評価しながら取り出すということを行っています。具体的には、型を取り出すときのキー(プロパティ名)をC extends { length: Num} ? 0 : 1という条件型で決定しています。

条件型には遅延評価されるという性質があるため、この型の宣言を見ただけでは01のどちらが返されるのか分かりません。これにより、宣言の段階でプロパティアクセス型が解決されるのを回避していま。

これも何だかTypeScriptの推論器の穴を突いている気がして、そのうち塞がれそうでちょっと怖いですね。しかしとにかくこれでエラーを回避しながら目的を達成できました。


まとめ

この記事では、タプル型を用いて最低要素数が決まっている配列の型を宣言するという手法を紹介し、さらにその最低要素数を数値リテラル型を用いて指定できるように一般化する方法を紹介しました。

ポイントは型レベル再帰の扱い方タプル型と配列型の相互変換といったテクニック、そしてタプル型をカウンタとして扱うというアイデアです。知識とアイデアがあればこれくらいのことができるというTypeScriptの面白さを知っていただけると幸いです。

ちなみに、型レベル再帰の回数には制限があるので注意してください。今回の場合だと、AtLeast<46, number>のように要素数を46以上にすると再帰の限界を超えてエラーになりました。


謝辞

この記事は以下のツイートから着想を得ました。この場を借りてお礼申し上げます。