注意
この記事では相当ルーズに等号などの記号を使っています。きっちり頑張るのはつらいので、ぜひ良い感じに読んでください。また、TypeScriptのバージョンは3.9を想定しています。
TL; DR
1.やりたいこと
長さ$n$のタプル型
$$
A = [ A[0], A[1], \dots, A[n - 1]]
$$
に対して、この並び順を逆転させた型
$$
\mathrm{Reverse} \langle A \rangle = [A[n - 1], A[n - 2], \dots, A[0]]
$$
を計算するようなジェネリクスReverse
を定義したいとします。この記事ではReverse
の実装を通して、タプル型の要素1つ1つを列挙・加工し、新たな型を定義する方法を説明します。
2.準備
Reverse
を定義する前に、必要となる型をあらかじめ定義しておきます。
$$
\mathrm{Append} \langle \mathrm{unknown}, A \rangle = [\mathrm{unknown}, A[0], A[1], \dots, A[n - 1]]
$$
であるようなジェネリクスAppend
を次のように定義します。
type Append<T, U extends unknown[]> = ((x: T, ...y: U) => void) extends (
...x: infer V
) => void
? V
: never;
3.添え字の計算
まず型のforループにあたるものを実装します。つまり、タプル["a", "b", "c"]
があるとして、このタプルの各要素"a","b","c"
に順番にアクセスする方法を考えていきます。
通常、配列の全列挙を行いたいときは添え字を1づつ増やしますが、TypeScriptにおいて型1
と型1
を足して型2
を得ることは(簡単には)できません。
そこで次のような方法を採ります。unknown
が$n$個だけ並んだタプルX<n>
を添え字$n$と対応させ、以下のよう書くことにします。ただし$[] \leftrightarrow 0$です。
$$
n \leftrightarrow X \langle n \rangle =\left[ \underbrace{\mathrm{unknown}, \dots, \mathrm{unknown}}_n \right]
$$
このとき
$$
n + 1 \leftrightarrow X \langle n + 1 \rangle = \mathrm{Append} \langle \mathrm{unknown}, X \langle n \rangle \rangle
$$
なので、for文におけるi++
にあたるものを計算できます。
また、$n$はX<n>
の長さなのでX<n>["length"]
で取り出せます。そのため、型X<i> extends { length: n } ? 1 : 0
は$i = n$のとき$1$、$i \neq n$のとき$0$なので、添え字がタプルの長さに達したときにループを止める操作も実装できそうに思えます。
試しに、再帰を使って次のような実装を書いてみます。
type Reverse<T extends unknown[]> = _Reverse<T, [], [], T["length"]>;
type _Reverse<
From extends unknown[],
To extends unknown[],
Index extends unknown[],
Limit extends number
> = Index extends { length: Limit }
? To
: _Reverse<
From,
Append<From[Index["length"]], To>,
Append<unknown, Index>,
Limit
>;
これは一見うまくいきそうですが、まだ少し足りません。TypeScriptは型の再帰を直接書くことができないため、この型定義はエラーを起こします12。
4.再帰的な型定義
先ほど「TypeScriptは型の再帰を直接書くことができない」と言いました。この項ではこれについて、もう少し詳しく説明します。
例えば、TypeScriptにおいて次のような型定義はできません。
type T = T;
これがなぜできないかというと、この定義を許した場合
- 右辺の
T
は型エイリアスなので、T
の定義の右辺T
に置き換える必要がある。 - その
T
も型エイリアスなので、T
の定義の右辺T
に置き換える必要がある。 - その
T
も……
といった具合で、置き換えがいつまでたっても終了しないためです2。
一方で、次の型定義は可能です3。
type T = Array<T>;
// const t: T = [[], [[]]];
2つの違いは「T
を定義するために、右辺の型エイリアスを調べつくす必要があるかどうか」にあります。2つ目の例では、T
を定義するにはT
がArray<any>
の部分型であることさえ知っていれば十分です。T
の要素にアクセスされるときまで、右辺の型エイリアスT
を調べる必要はありません。なので、TypeScriptのコンパイラをこのような型定義を許すように実装できます。
以上の理屈だと、次の型定義はできないことになります(実際できません)。
type T = Array<T>[0];
なのですが、次の型定義は可能です。
type T<U extends 0 | 1> = {
0: U,
1: T<U>
}[U];
この型定義はプログラムのどこかにT<1>
と書いたとたんにエラーを起こしますが、T<1>
がプログラム中に表れない限りはエラーを起こしません。T
を定義するときにT<1>
を計算する必要はない(し、コンパイラも計算しない)からです。
上の例は少し非現実的ですが、例えば長さ1で深くネストされたタプル[[[1]]]
から1
を取り出すジェネリクスFlatten
は以下のように定義できます。
type Flatten<T> = {
0: T,
1: T extends [infer U] ? Flatten<U> : never,
}[T extends [unknown] ? 1 : 0];
5.Reverseの実装
以上を踏まえてReverse
を定義します。第3節の型定義を少し変形して、第4節のしかけを導入します。
type Reverse<T extends unknown[]> = _Reverse<T, [], [], T["length"]>;
type _Reverse<
From extends unknown[],
To extends unknown[],
Index extends unknown[],
Limit extends number
> = {
0: To;
1: _Reverse<
From,
Append<From[Index["length"]], To>,
Append<unknown, Index>,
Limit
>;
}[Index extends { length: Limit } ? 0 : 1];
この型定義は確かにうまくいきます(コードサンプルで確認できます)。
さいごに
この記事は@uhyoさんの記事と再帰的な型定義の公式解説を特に参考にしています。なんならこの2つを見たほうが早いかもしれません。
参考文献
- TypeScriptで最低n個の要素を持った配列の型を宣言する方法
- TypeScriptの型入門
- TypeScriptの型初級
- TypeScript: Handbook - TypeScript 3.7