今日、Anders HejlsbergさんによってTypeScript 4.0に導入予定の新機能のプルリクエストが出されました。この記事では、この新機能Variadic Tuple Typesを解説します。
ちなみに、現在(この記事の執筆時)のTypeScriptのバージョンは3.9であり、4.0はその次のバージョンです。一見メジャーバージョンアップに見えますが、TypeScriptはsemantic versioningを採用していないため、3.9 → 4.0は特別なリリースというわけではなく、3.8 → 3.9とかと規模は同程度です。
Variadic Tuple Typesの基本
さて、Variadic Tuple Typesは、一言でいえばタプル型の中に...Tと書ける機能です。この構文はよく知られたスプレッド構文のいわば型バージョンであり、あるタプル型の要素たちを別のタプル型に埋め込むことができます。次の例では、この機能をRepeat2型の定義で使っています。
type Repeat2<T extends readonly any[]> = [...T, ...T];
// type SNSN = [string, number, string, number]
type SNSN = Repeat2<[string, number]>;
// type BSNSNB = [boolean, string, number, string, number, boolean]
type BSNSNB = [boolean, ...SNSN, boolean]
ここで定義されているRepeat2は、与えられたタプル型(または配列型)の中身を2回繰り返した型を得られる型です。これを使って作られたSNSN型は、[string, number]を2回繰り返した型なので[string, number, string, number]です。さらに、BSNSNBはSNSNの両端をbooleanで囲んだタプル型として定義されており、これは[boolean, string, number, string, number, boolean]に解決されます。
Variadic Tuple Typesのすごい点
Variadic Tuple Typesの特にすごい点は、型変数に対して...を使用できる点です。上の例ではRepeat2がこれをやっています。...で展開される型は、extends readonly any[]という条件(またはより厳しい条件)を満たす必要があります。この条件は、任意の配列型またはタプル型が満たす条件です。
特に、型変数を型変数のまま扱える点が画期的です。これが意味することは、タプル型の中で...Tとして使われている型変数Tが推論の対象になるということです。
Variadic Tuple Typesでは、このような記法を導入したことに加え、型推論なども強化されて...Tに対してしっかり推論が働くようになります。この記事の後半で見ますが、このことがVariadic Tuple Typesを強力な道具にしています。
従来の可変長タプルとの違い
ところで、TypeScriptには従来から可変長タプル型という機能がありました。例えば次のような型を宣言することは従来から可能だったのです。
type NNSs = [number, number, ...string[]];
これは「最初がnumber、次もnumber、残りが0個以上のstring」という条件を満たす配列型です。
こう見ると、昔から...が使えたように思えますが、従来は2つの制限がありました。
-
...が使えるのは最後に1回のみ。 - 必ず
...X[]の形で使う必要がある。
つまり、従来は「残りが0個以上のX」ということしか表現できなかったのです。最後以外で...を使うのはエラーとなりますし、最後でも次のような場合は...X[]の形になっていないのでエラーとなります。
// TypeScript 3.9以下ではエラー
// エラー :A rest element type must be an array type.
type NTs<T extends readonly any[]> = [number, ...T];
たとえTが配列であることがわかっていたとしても、従来は...Tは許されませんでした。これは...X[]の形ではないからです。
まあ、...Tが許されるとTが配列ではなくタプル型(固定長)かもしれず、[number, ...T]が必ず可変長になるとは限らないという新たな可能性が発生してしまうので、従来は必ず可変長になる形でしか使えないように制限されていたと見ることができます。
Variadic Tuple Typesでできないこと
逆に、TypeScript 4.0でVariadic Tuple Typesが導入されてもできないことはあります。具体的には、次のような型は定義できません。
// エラー: A rest element must be last in a tuple type.
type SsNs = [...string[], ...number[]];
型SsNsの意図は「前半にstringが並んでいて後半にnumberが並んでいる配列」というものですね。上の3つは「文字列→数値」という順番なのでOKで、残り2つはそうなっていません。
// ご注意:これは動きません!
const valid1: SsNs = ["foo", "bar", "baz", 0, 1, 2];
const valid2: SsNs = ["foo", 0, 1, 2];
const valid3: SsNs = [0];
const invalid1: SsNs = [0, 1, 2, "foo"];
const invalid2: SsNs = ["foo", 0, "bar", 1];
ところが、このような型定義はできません。SsNsの型定義の時点でコンパイルエラーとなります。
整理すると、Variadic Tuple Typesが導入されても、長さが可変長の部分は最後だけという制限は残ります。上のSsNsは、...string[]という可変長部分を最後以外の部分に置こうとしているため、TypeScriptのサポート外です。同様に、[...string[], number]のようなものもサポートされません。
しかし、ここにひとつ厄介な問題があります。先ほどVariadic Tuple Typesの...Tで展開できるTの条件はreadonly any[]の部分型であることと述べました。つまり、Tは固定長のタプル型とは限らずstring[]のような配列型かもしれないということです。ということは、次のようにNSsN型を定義してもコンパイルエラーにはなってくれません。
type AddN<T extends readonly any[]> = [number, ...T, number];
// type NSBN = [number, string, boolean, number]
type NSBN = AddN<[string, boolean]>;
// type NSsN = [number, ...(string | number)[]]
type NSsN = AddN<string[]>;
この例のAddNは、与えられたタプル型Tの最初と最後にnumberを足した新しいタプル型を得るための型です。想定された使い方は、NSBNのように固定長のタプル型をいじるものです。
しかし、この定義だとNSsNの定義のように、このAddNにstring[]のようなものを渡すことができます。現状、型変数に対して「固定長のタプル型のみ」のような制限を課す方法がないため、このような物は型エラーにはできないのです。
このような場合は、可変長の部分以降が全部吸収されます。具体例は上のNSsNで、これは[number, ...(string | number)[]]となります。本来意図していたものは[number, ...string[], number]ですが、このようなものはTypeScriptでサポートされませんので、解決の際に...string[]の後ろのnumberが...string[]に吸収されて...(string | number)[]となります。
この“吸収”という動作はPRの説明にも記載されており、バグではなく仕様通りの動作です。意図と異なる結果となるため戸惑う方が多そうですが、前述のようにTypeScriptではタプル型の最後以外の位置での可変長要素を(少なくとも今の段階では)サポートしませんから、やむを得ない挙動であると考えられます。
まとめると、タプル型の最後以外に可変長の要素(...string[]のようなもの)がある型はTypeScriptでは今のところ表現できません。構文上明らかな場合([...string[], ...number[])は宣言の段階でコンパイルエラーとして弾かれますが、型変数が配列型(可変長)かタプル型(固定長)か分からない場合には必ずしも弾くことができません。そのような場合は“吸収”の挙動により最後が可変長のタプル型に矯正されます。
関数宣言でVariadic Tuple Typesを使う
さて、Variadic Tuple Typesは...で(特定の条件を満たす)型変数も展開できるのが良い点です。これで特に嬉しいのは、関数宣言(特にジェネリクス)と一緒に使えることです。
たとえば、こんな関数を作ることができます。
function repeat<T extends readonly any[]>(arr: [...T]): [...T, ...T] {
return [...arr, ...arr];
}
// const t: [string, number, string, number]
const t = repeat(["foo", 1]);
ここで定義された関数repeatは、与えられた配列を2回繰り返した配列を返す関数です。Variadic Tuple Typesによって、この関数には<T extends readonly any[]>(arr: [...T]) => [...T, ...T]という型を付けることができます。
ちなみに、引数の[...T]は今回新しく追加されたテクニックで、Tとして配列型ではなくタプル型を推論してほしいときに使えます。意味的には[...T]はただのTと同じですが、型引数の推論の際の挙動が異なるのです。
今回repeat(["foo", 1])のようにrepeatを呼び出すと、引数["foo", 1]に[...T]が当てはめられるため、型引数Tは[string, number]として推論されます。よって返り値の型が[...T, ...T]なので、変数tは[string, number, string, number]型と推論されます。
型引数の推論もさることながら、配列リテラル中のスプレッド構文の型推論に対しても拡張が加えられています。具体的には、[...T]型の変数arrに対して、[...arr, ...arr]という式の型は[...T, ...T]であることの推論ができていますね。
他の例としては、次のようなtail関数も定義できます。
function tail<S, T extends readonly any[]>(tuple: readonly [S, ...T]): T {
const [, ...rest] = tuple;
return rest;
}
// const t2: [number, string, number]
const t2 = tail(["foo", 1, "bar", 2]);
このtail関数は、与えられた配列の最初の要素を除いた配列を返します。使用例では[string, number, string, number]型の配列を引数に与えたので、返り値の型は[number, string, number]となっています。この場合も、与えられた引数から型引数S, Tを推論することに成功しています。具体的には、["foo", 1, "bar", 2]がreadonly [S, ...T]とマッチングされたことによって、SがstringでありTが[number, string, number]であることを推論しています。
なお、お察しの通り、型がリテラル型ではなくstringやnumberなどに広げられるのが気に入らない場合はas constを使って解決できます。
// const t3: [1, "bar", 2]
const t3 = tail(["foo", 1, "bar", 2] as const);
関数tailの定義でわざわざ引数のタプル型にreadonlyとつけていたのは、as constによって作られるreadonlyタプルを受け入れられるようにするためです。筆者の以前の記事「TypeScriptのreadonlyプロパティを使いこなす」では以下のように述べましたが、Variadic Tuple Typesの登場によって適切なreadonlyの付与は今後さらに重要になるでしょう。
readonlyを使いこなすには、可能なところにできる限りreadonlyを付与します。特に、関数引数が受け取るオブジェクトや配列は、関数内で変更しないならば積極的にreadonly`を付与しましょう。
ちなみに、tailの中身に目を向けると、分割代入の型推論に対してもやはり強化が入っていることが伺えます。具体的には、次の箇所でrestにちゃんと[...T]型が与えられます。
// const rest: [...T]
const [, ...rest] = tuple;
可変長引数とVariadic Tuple Types
Variadic Tuple Typesの面白いところは、可変長引数と組み合わせることができる点です。次のようにすると、「可変長引数だが最初と最後の引数はnumberでなければならない」という謎の関数を作ることができます。
function func<Args extends readonly any[]>(...args: readonly [number, ...Args, number]) {
const num1 = args[0];
const num2 = args[args.length - 1] as number;
console.log(args.slice(1, -1));
return num1 + num2;
}
func(1, 2, 3, 4, 5);
func(1, "foo", "bar", 2);
// エラー: Argument of type 'string' is not assignable to parameter of type 'number'.
func("foo", 1, 2, 3);
// エラー: Expected 2 arguments, but got 1.
func(1);
Conditional TypesとVariadic Tuple Types
Variadic Tuple Typesは、Conditional Types(特にinfer)と組み合わせることもできます。たとえば、「タプル型の最初の要素を削ったタプル型を返す型関数」は次のように定義できるでしょう。
type Tail<T extends readonly [any, ...any[]]> = T extends readonly [any, ...infer U] ? U : never;
// type SB = [string, boolean]
type SB = Tail<[number, string, boolean]>;
TypeScript 3.9以前では、...infer Uが不可能でした(...X[]の形ではないため)。そのため、同じことをするには次のように関数型を経由する必要がありました。Variadic Tuple Typesによりタプル型の表現力が向上したことで、より直感的にTailのような型が書けるようになったのは嬉しいですね。
type Tail<T extends readonly [any, ...any[]]> = ((...args: T) => void) extends (arg0: any, ...args: infer U) => void ? U : never;
同様に、タプル型の最初に型を付け加えるConsも簡単に定義することができるようになりました。というか、これならもはやConsを定義する必要すらありませんね。
type Cons<U, T extends readonly any[]> = [U, ...T];
// type SSB = [string, string, number]
type SSB = Cons<string, [string, number]>;
配列リテラル・スプレッド構文に対する推論の変化
最後に、Variadic Tuple Typesによってもたらされる間接的な推論の変化について述べておきます。冒頭にでてきたrepeatを少し変えた関数を考えてみます。
function repeat<T extends readonly any[]>(arr: T) {
const result = [...arr, ...arr];
return result;
}
これはVariadic Tuple Typesの構文を使っていないので、TS 3.9以前でもコンパイルできる関数です。しかし、型推論の結果はTS 4.0から変化します。
TS 3.9では、変数resultがany[]型に推論されます。よって、repeatの返り値の型はany[]です。なかなか危険ですね。
一方、TS 4.0ではresultの型はT[number][]に推論されます。これは「T[number]の配列」であり、T[number]とは「Tの要素の型」です。たとえばTが[string, boolean]というタプル型ならば、T[number]はstring | booleanになります。よって、Tが[string, boolean]だったとしたら、repeatの返り値の型は(string | boolean)[]となるわけですね。実際、resultという配列の要素は全てarrの要素(つまりTの要素)ですから、この推論は妥当ですね。
このように、Variadic Tuple Typesの導入に伴って配列リテラルのスプレッド構文の推論が強化されています。
ところで、冒頭に開設した流れだとrepeatの返り値の型はTS 4.0では[...T, ...T]となるべきだと思われますが、そうはなっていませんね。実は、[...T, ...T]を得るためには配列リテラルに対してas constが必要です。次のように変えれば、変数resultの型はreadonly [...T, ...T]となります(TS3.9ではreadonly any[]になります)。
function repeat<T extends readonly any[]>(arr: T) {
const result = [...arr, ...arr] as const;
return result;
}
このように、タプル型をスプレッドさせる場合でも、配列リテラルにas constを用いないと結果がタプル型にならないことは特筆に値します。
タプル型に対するインデックスアクセスに対する推論
Variadic Tuple Typesにより「型引数を内包するタプル型」が扱えるようになったことから、TS 4.0では与えられた配列の最初の要素を返す関数headを次のように書くことができます。
function head<T extends readonly any[]>(args: readonly [...T]) {
const result = args[0];
return result;
}
// const str: "foo"
const str = head(["foo", "bar"] as const);
なんと、変数strの型を見るとちゃんと"foo"が推論されます。実は、この関数headの返り値の型を調べるとreadonly [...T][0]となっています(これはreadonly [...T]の0番目の要素という意味です)。
このように、具体的ではない(型変数で表される)型を持つ配列に対してarr[0]のようにインデックスアクセスをした場合にLookup Typeが推論されるのが特徴的です。実際にheadを上記の例のように呼び出すと、Tがreadonly ["foo", "bar"]に推論されて、readonly [...T][0]が"foo"に解決されます。
この推論機構は、argsの型を[...T]のようにタプル型と明示しないと使えません。これをargs: Tとした場合、返り値はany型になってしまいます。
ところで、args[0]を返すとなると、空の配列[]を渡したときの挙動が気になりますよね。つまり、Tが[]だった場合にreadonly [...T][0]は何になるのかということです。早速やってみましょう。
// const u: undefined
const u = head([])
なんと、head([])の返り値はundefined型になりました。実際、空の配列にに対して0番目の要素を取得するとundefinedが返りますから、これは妥当な型推論です。天才ですね。
まとめ
Anders Hejlsbergさんの天才的な成果により、TypeScript 4.0からはVariadic Tuple Typesが使えるようになる見込みです。
Variadic Tuple Typesの最大の特徴はタプル型の中に...Tのようなスプレッド構文を混ぜることができる点であり、また具体化される前の型変数が混ざったタプル型をうまく扱えるようになるのもとても重要です。
今後我々に要求されるテクニックは、Tとしてタプル型が求められる場合ただTと書くのではなくreadonly [...T]と書くことです。こうすることによって、Variadic Tuple Typesの機能を最大限活かすことができるでしょう。
なお、この記事を公開した時点でVariadic Tuple TypesのPRはまだマージされていませんので、いますぐ試したい方は自分でTypeScriptコンパイラをビルドする必要があります。この記事の内容は、当該PRの現時点の最新コミットである759352206d6c411e03ffb096a14bc9deab841dacに基づいています。