先日、TypeScript 5.6 Betaが公開され、あわせてリリースノートも出ました。
この記事では、TS 5.6の新機能の中でもIterator helpersに注目します。特に、Iterator helpersのサポートに合わせて型定義に追加されたBuiltinIteratorsやBuiltinAsyncIteratorsについて解説します。
この記事の内容はTS 5.6 Betaの時点のものです。TS 5.6の正式版では型名などに修正が入り、この記事の内容は古くなりました。
この記事は当時の状態や歴史を知るためにこのまま残してあります。最新の状況は以下の記事を参照してください。
Iterator helpersとは
Iterator helpersは、ECMAScriptのプロポーザルのひとつであり、この記事の公開時点ではStage 3という完成目前の状態にあります。TypeScriptではStage 3に到達したプロポーザルが実装されるので今回これが実装されることになりました。
ランタイムの実装としては、この記事公開時点ではGoogle Chrome 122, Node.js 22.0.0, Deno 1.42などに実装されています。
Iterator helpersは、イテレータにいくつかのメソッドが追加されて便利になるというものです。
イテレータは、すごく大ざっぱに言えば配列を抽象化して遅延評価を加えたものです。イテレータはES2015で導入され、それ以降の言語仕様ではイテレータが積極的に用いられています。例えば、配列を受け取るように見える関数が実はイテレータを受け付けるということも多くあります。
/**
* start以上end以下の数を順に出力するイテレータを返す
*/
function* range(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) {
yield i;
}
}
// イテレータはfor-of文に渡して使うことができる
for (const v of range(0, 10)) {
console.log(v);
}
// new Setも実は配列だけでなくイテレータを受け取ることができる
const nums = new Set(range(0, 10));
console.log(nums); // Set (11) {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
また、組み込み関数がイテレータを返す場合もあります。例えば、Setのvaluesメソッドはイテレータを返します。イテレータはそのままfor-of文などに渡すこともできますが、配列の形でほしい場合はArray.fromで配列にできます。
const nums = new Set([1, 2, 3, 4, 5]);
const values = nums.values();
console.log(values); // SetIterator オブジェクト
const array = Array.from(values);
console.log(array); // [1, 2, 3, 4, 5]
従来、イテレータはnext以外にメソッドを持っておらず、不便な存在でした。配列のfilterメソッドのような加工が不可能で、そのような操作を行いたければイテレータを一度配列にするか、あるいはジェネレータ関数を駆使して手書きで実装する必要がありました。
Iterator helpersでは、イテレータ自体にメソッドが追加され、イテレータの利便性が向上します。具体的には、以下のメソッドが追加されます。
- drop
- every
- filter
- find
- flatMap
- forEach
- map
- reduce
- take
- toArray
drop, take, toArray以外は配列に同名のメソッドがあるので理解しやすいでしょう。dropとtakeは敢えて配列のメソッドと比べるとsliceが近いですね。toArrayは、従来Array.fromでできていたイテレータの配列化がメソッドになったものです。
いくつか具体例を見てみましょう。
/**
* 与えられたSet<number>のうち、偶数のみを出力するイテレータを返す
*/
function iterateEvenNumbers(
set: Set<number>
) {
return set.values().filter(n => n % 2 === 0);
}
const nums = new Set([1, 2, 3, 4, 5]);
for (const v of iterateEvenNumbers(nums)) {
console.log(v); // 2, 4が出力される
}
この例は、配列が出てきません(Setを作るところは本題に関係ないので許してください)。Setの中身をvaluesで取り出してから最終的にfor-of文に入力するまで、イテレータだけで完結しています。
従来であればこうはいかず、次のようにする必要があったでしょう。この例では、関数の返り値をイテレータにするのを諦めて配列にしています。
/**
* 与えられたSet<number>のうち、偶数のみを出力する配列を返す
*/
function getEvenNumbers(
set: Set<number>
) {
return Array.from(set.values()).filter(n => n % 2 === 0);
}
const nums = new Set([1, 2, 3, 4, 5]);
for (const v of getEvenNumbers(nums)) {
console.log(v); // 2, 4が出力される
}
どうしても配列を介したくない場合は、ジェネレータ関数を使う次のようなやり方になるでしょう。このやり方では、filterのような便利なメソッドが使えずif文の手書きになってしまいました。
/**
* 与えられたSet<number>のうち、偶数のみを出力するイテレータを返す
*/
function* iterateEvenNumbers(
set: Set<number>
) {
for (const v of set) {
if (v % 2 === 0) {
yield v;
}
}
}
const nums = new Set([1, 2, 3, 4, 5]);
for (const v of iterateEvenNumbers(nums)) {
console.log(v); // 2, 4が出力される
}
ジェネレータ関数を使いたくないけど配列にもしたくない場合は、もうiterateEvenNumbersの関数化を諦めるしかありません。
const nums = new Set([1, 2, 3, 4, 5]);
for (const v of nums) {
if (v % 2 === 0) {
console.log(v); // 2, 4が出力される
}
}
ということで、従来例と比較すると、iterator helpersが「配列を介さない」ことと「きれいで分かりやすいコード」を両立するために有用であることが分かりますね。
IteratorとIterable
ここからこの記事の本題に入っていきます。まずは、TS 5.6より前からあった用語を整理します。具体的には、IteratorとIterableです。
Iterator(イテレータ)は、JavaScriptにおけるイテレータ本体であるオブジェクトのことです。Iteratorは、nextメソッドを持つのが特徴です。例えば、先ほどから使ってきたSetのvaluesの返り値はイテレータであり、nextメソッドを持っています。
const nums = new Set([1, 2, 3, 4, 5]);
const iterator = nums.values(); // これがIterator
console.log(iterator.next); // function next() { [native code] }
イテレータの中身に対してループするときはfor-of文を使うのが主流でしたが、手動でnextメソッドを呼び出すことによってもループをするのは一応可能です。
const nums = new Set([1, 2, 3, 4, 5]);
const iterator = nums.values(); // これがIterator
while (true) {
const result = iterator.next();
if (result.done) {
break;
}
console.log(result.value); // 1, 2, 3, 4, 5 が出力される
}
for-of文も、内部で上記のようなwhile文とだいたい同じような挙動をしています。
Iterableは、「イテレータを生み出すことができるオブジェクト」であり、[Symbol.iterator]メソッドを持つことが特徴です。例えば、配列やSetはIterableです。
よく考えてみると、次のようにfor-of文に配列を渡すことができますが、配列はイテレータではありません(nextメソッドを持ちません)。
const arr = [1, 2, 3, 4, 5];
console.log(arr.next); // undefined
for (const v of arr) { // でも配列はfor-of文でループできる
console.log(v);
}
実は、for-of文に渡すことができるのはイテレータではなくIterableなのです。Iterableが渡されたら、内部的にイテレータが作成され、それに対してループが行われるのです。手動のwhile文で表すとこういう感じです。
const arr = [1, 2, 3, 4, 5];
const iterator = arr[Symbol.iterator](); // Iterableからイテレータを得る
while (true) {
const result = iterator.next();
if (result.done) {
break;
}
console.log(result.value); // 1, 2, 3, 4, 5 が出力される
}
以上のコードではSetや配列など組み込みのオブジェクトに頼ってイテレータを作っていましたが、イテレータ及びIterableのルールに従っていれば、独自にイテレータやIterableを作ることも可能です。
const oreoreIterable = {
[Symbol.iterator]() {
// Iteratorを作って返す
let current = 1;
return {
next() {
if (current <= 5) {
return {
done: false,
value: current++
};
}
return {
done: true,
value: undefined,
};
}
};
}
}
for (const v of oreoreIterable) {
console.log(v); // 1, 2, 3, 4, 5 が出力される
}
このように、従来、IteratorやIterableというのはそういうクラスがあるわけではなく、インターフェースを指す言葉でした。このことを指して「Iteratorプロトコル」のように言うこともあります。
ちなみに、なぜIterableとIteratorが分かれているのかについても、上の例を見れば少し分かるのではないでしょうか。Iterable (oreoreIterable) は状態を持たないイミュータブルなオブジェクトですが、イテレータ([Symbol.iterator]の返り値)はthis.currentという状態を持つオブジェクトです。イテレータはnextメソッドが呼ばれるたびに次の値を返すというインターフェースになっているため、必然的に状態を持ちます。配列やSetといったものがIterableに相当すると考えると、それらに対する1回のイテレーションを担当するイテレータオブジェクトは別に作られる必要があることが分かります。
TypeScriptにおけるIteratorとIterable
ここからやっとTypeScriptの話題に入ります。TypeScriptにおいては、IteratorもIterableも型名として存在しています。これらの型名を先ほどのコードに付加するとこのようになります。
const oreoreIterable: Iterable<number> = {
[Symbol.iterator](): Iterator<number> {
// Iteratorを作って返す
let current = 1;
return {
next() {
if (current <= 5) {
return {
done: false,
value: current++
};
}
return {
done: true,
value: undefined,
};
}
};
}
}
また、「俺は直接Iteratorを作りたいんや!! Iterableなんか要らん!!」という人のために、IteratorでもありIterableでもあることを表す IterableIteratorも用意されています。
let current = 1;
const oreoreItertor: IterableIterator<number> = {
[Symbol.iterator]() {
return this;
},
next() {
if (current <= 5) {
return {
done: false,
value: current++
};
}
return {
done: true,
value: undefined,
};
}
};
for (const v of oreoreItertor) {
console.log(v); // 1, 2, 3, 4, 5 が出力される
}
このように、Iteratorを直接作ってfor-of文とかに渡したい場合に、thisを返す[Symbol.iterator]メソッドを用意して渡せるようにするのがIterableIteratorです。関数がIteratorを返す場合などにIterableIteratorの概念は有効です。
TS 5.6でのBuiltinIteratorの導入
ところで、Setのvaluesのような組み込みの関数から得られたイテレータには、どんな型が付いているのでしょうか。
const nums = new Set([1, 2, 3, 4, 5]);
const values = nums.values();
// ^?
実は、これはTS 5.6で大きく変わったところです。
TS 5.5までは、valuesの型はIterableIterator<number>でした。単にnumberのイテレータであることに加えて、for-of文などで扱えるようにIterableであることを表しています。
一方、TS 5.6ではBuiltinIterator<number>になります。
お察しのように、これはTS 5.6で新しく導入された型であり、イテレータがfilterなどのメソッドを持っているのはBuiltinIteratorのメソッドとして定義されているからです。
つまり、TS 5.6では、Setのvaluesメソッドなど、組み込み関数でIterableIteratorを返すもの型定義はことごとくBuiltinIteratorに書き換えられたというわけです。
Iterator helpersはランタイムではどこに定義されているのか
ここからややこしい話に入っていきます。実行時において(つまりJavaScript仕様において)、iterator helpersのメソッドはどこに定義されているのでしょうか。その答えは、Iterator.prototypeです。実は、組み込みの関数が返すイテレータはIteratorのインスタンスなのです。
const nums = new Set([1, 2, 3, 4, 5]);
const values = nums.values();
// ^?
console.log(values instanceof Iterator); // true
console.log(values.filter === Iterator.prototype.filter); // true
つまり、Iteratorというクラスがグローバルに定義されていることになります。これはiterator helpersの導入と同時に追加されました。
名前のミスマッチとその結果
ここで、違和感に気づいた方もいるでしょう。TypeScriptにおいては、「XというクラスのインスタンスはX型を持つ」というのが基本です。
const s = new Set([1, 2, 3]); // sはSet<number>型
const d = new Date(); // dはDate型
class MyClass {}
const obj = new MyClass(); // objはMyClass型
しかし、Iteratorにおいてはそれは満たされていません。Iteratorというクラスのインスタンスは、Iterator型ではなくBuiltinIteratorです。
どうして、このように慣習と違う定義になってしまったのでしょうか。その理由は、TS 5.6 Betaのリリースブログで unfortunate name clash (不幸な名前の衝突)と説明されています。
お察しのとおり、TypeScriptには以前からIterator型がすでに存在していたので、破壊的変更を避けるために、新しい概念である「Iteratorクラスのインスタンス」の型をIteratorにすることができなかったのです。そのため、TypeScriptでは「型としてのIteratorと値(グローバル変数の名前)としてのIteratorは無関係」というややこしい状態になってしまいました。
TypeScriptにおけるIterator型は、これまでもこれからも「イテレータのプロトコルに従う型」の意味です。昔からnextメソッドさえ持っていればイテレータとしては適格なオブジェクトであり、iterator helpersが導入されてもそれは変わりません。Iterator helpersは、組み込み関数から生み出されるイテレータは、イテレータに最低限必要なnextメソッドに加えて、追加の便利なメソッドを持っていますよという機能です。
strictBuiltinIteratorReturn
同じくTS 5.6 Betaで導入された strictBuiltinIteratorReturn という新しいコンパイラオプションについても解説します。名前がstrictで始まっていることから想像されるように、このオプションはstrictファミリーの一つです。そのため、strictが有効にされていると自動的にこれも有効になります。
このオプションを理解するためには、まずBuiltinIteratorを返す関数の型定義を見てみましょう。先ほどから使ってきたSetのvaluesメソッドは、こんな定義になっています。
interface Set<T> {
// ...
/**
* Returns an iterable of values in the set.
*/
values(): BuiltinIterator<T, BuiltinIteratorReturn>;
}
よく見るとBuiltinIteratorの型引数が2つあります。1つ目はイテレータが出力する要素ですが、2つ目はなんでしょうか。
実は、BuiltinIteratorは型引数が3つあります。それどころか、元々ただのIteratorにも型引数が3つありました。
interface Iterator<T, TReturn = any, TNext = any> {
// ...
}
interface BuiltinIterator<T, TReturn = any, TNext = any> {
// ...
}
これによると、2つ目の型引数はTReturnです。この型引数が使われるのは、nextの返り値です。nextの返り値はオブジェクトで、doneプロパティとvalueプロパティを持ちます。doneがfalseのときはvalueの型はTですが、doneがtrueのときはvalueの型はTReturnなのです。
interface IteratorYieldResult<TYield> {
done?: false;
value: TYield;
}
interface IteratorReturnResult<TReturn> {
done: true;
value: TReturn;
}
type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
interface Iterator<T, TReturn = any, TNext = any> {
// NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
next(...[value]: [] | [TNext]): IteratorResult<T, TReturn>;
// ...
}
つまり、イテレータが全部の要素を出し終わって終了するときに、終了時の値として最後に与えられるのがTReturnなのです。
しかし、この「終了時の値」は仕様として存在しているものの、全然活用されておらず事実上闇に葬られています。例えば、for-of文にイテレータを渡したときは「終了時の値」は無視されます。
ここでの問題は、型引数TReturnのデフォルトがanyであることです。よって、例えばBuiltinIterator<number>と書いたら実はBuiltinIterator<number, any, any>と同じ意味になるということです。普段使われないとはいえ、anyが紛れるのはよくありません。
Iteratorの時点ですでにデフォルトがanyになっていたのですが、この定義に依存しているTSコードが存在することを考えると、デフォルトを変えるのは後方互換性の観点から困難です。
唐突な宣伝: 筆者が開発しているbetter-typescript-libは後方互換性を必要としない人向けの標準ライブラリであり、TReturnのデフォルト値はunknownに修正されています。
しかし、新しく導入されたBuiltinIteratorであればanyという負の遺産を引き継がなくてもいいのではないか? というアイデアで導入されたのがBuiltinIteratorReturnです。もう一度Setのvaluesの定義を見てみましょう。
interface Set<T> {
// ...
/**
* Returns an iterable of values in the set.
*/
values(): BuiltinIterator<T, BuiltinIteratorReturn>;
}
TReturnがBuiltinIteratorReturnとなっていますが、実はこのBuiltinIteratorReturnという型は、strictBuiltinIteratorReturnが有効のときはundefinedになり、無効のときはanyになるという特殊な型となっています。
これにより、このオプションが有効な状況ではSetのvaluesのようなイテレータを返す組み込み関数の返り値はBuiltinIterator<number, undefined> のような型となり、anyが削減されています。
もし型定義を見ていてBuiltinIteratorReturnが出てきたら、このことを思い出しましょう。
TypeScriptでiterator helpersを扱うにあたって注意すること
命名のややこしさはあるものの、iterator helpersに関する型定義はうまく書かれています。我々は特に苦労することなく、iterator helpersの便利さを享受することができるでしょう。
しかし、型注釈を明記するときはすこし注意しましょう。イテレータを変数に入れる際、本当はBuiltinIteratorなのに変数の型をIteratorにしてしまった場合は、iterator helpersのメソッドが使えなくなってしまいます。
const nums = new Set([1, 2, 3, 4, 5]);
const values: Iterator<number> = nums.values();
const evenValues = values.filter(n => n % 2 === 0);
// ^^^^^^ 型エラーになってしまう!
この場合、valuesの型注釈を消せば型がBuiltinIterator<number, undefined, any> (strict有効の場合)となりfilterが使えるようになります。
このように、昔に書かれた型注釈がiterator helpersの使用を妨げてしまうこともあるので注意しましょう。また、新たに型注釈を書くときは、Iteratorを使うべきなのかBuiltinIteratorなのか、あるいはIterableなどほかの型が適しているのかを考える必要があります。この記事には出てきませんでしたがGeneratorなどもあります。
また、BuiltinIteratorについても、型定義では先ほど触れたBuiltinIteratorReturnが使われているので、それを忘れてBuiltinIterator<number>のように書くのは多少危険です(正直大差ないとはいえ)。
const nums = new Set([1, 2, 3, 4, 5]);
const values: BuiltinIterator<number> = nums.values();
// ↑ 型推論に任せたら BuiltinIterator<number, undefined, any> なのに、
// 型注釈を書いたら BuiltinIterator<number, any, any> になってしまった!
この点については筆者としては不満をかんじたので、issueで聞いてみています。
https://github.com/microsoft/TypeScript/issues/59444
余談
TS 5.6 Betaでは、BuiltinIteratorに加えてBuiltinAsyncIteratorの定義も追加されています。今のところasyncイテレータのメソッドは用意されていないのでこの型が存在する意味は特にないのですが、将来的にasyncイテレータにもメソッドが追加されるときに備えてBuiltinAsyncIteratorも用意されたようです。
ちなみに、最初はAsyncBuiltinIteratorという名前になりそうだったのですが、petamorikenさんの活躍により、より適した名前と思われるBuiltinAsyncIteratorに直されました。
まとめ
この記事では、TS 5.6で追加されたiterator helpersについて、型定義周りの事情を中心に解説しました。特に、iterator helpersのメソッドたちの実際の定義は新たに導入されたBuiltinIterator型に住むことになるので、この型を知っておくことは重要です。