先日、TypeScript 5.6 Betaが公開され、あわせてリリースノートも出ました。
この記事では、TS 5.6の新機能の中でもIterator helpersに注目します。特に、Iterator helpersのサポートに合わせて型定義に追加されたBuiltinIterators
やBuiltinAsyncIterators
について解説します。
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
型に住むことになるので、この型を知っておくことは重要です。