ENCA 3日目: プリミティブイテラブルを退ける規約制定
イテラブル(反復可能)とは
ES2015 から仕様の中にイテラブルインターフェースとイテレーターインターフェースが定義されています。イテラブルインターフェースを実装したオブジェクトやプリミティブのことを単にイテラブル(反復可能)と呼びます。
ざっくり TypeScript の型で表現すると以下のようになります(実際の TypeScript での型はジェネリクスになっています)。
interface Iterable {
[Symbol.iterator](): Iterator;
}
interface Iterator {
next(value?: any): IteratorResult;
return?(value?: any): IteratorResult;
throw?(error?: any): IteratorResult;
}
interface IteratorResult {
done: boolean;
value: any;
}
イテラブルに対しては for...of や配列形式の分割代入などのシンタックスを使うことが出来ます。
const iterable = {
[Symbol.iterator]() {
let counter = 0;
return {
next() {
if (counter >= 10) {
return { done: true, value: undefined };
}
return { done: false, value: counter++ };
},
};
},
}
for (const value of iterable) {
console.log(value); // 0, 1, 2, ..., 9
}
詳しくは JSConf JP 2024 の sosukesuzuki さんの発表が参考になるかと思います。
プリミティブイテラブルについて
ECMAScript の文字列はプリミティブイテラブルです。String.prototype[Symbol.iterator]
メソッドが定義されており、コードポイント単位で値を取り出すことが出来ます。
for (const char of "abcde") {
console.log(char); // "a", "b", "c", "d", "e"
}
他のプリミティブに対してもプロトタイプ汚染をして Symbol.iterator
メソッドを定義することで無理矢理イテラブルにすることが可能です(⚠️遊び以外で絶対にやらないでください)。
Number.prototype[Symbol.iterator] = function() {
const limit = this;
let counter = 0;
return {
next() {
if (counter >= limit) {
return { done: true, value: undefined };
}
return { done: false, value: counter++ };
},
};
};
for (const num of 10) {
console.log(num); // 0, 1, 2, ..., 9
}
プリミティブイテラブルを許容したのは失敗だった
今日では ECMAScript でプリミティブイテラブルを許容したのは失敗だったと捉えられるようになりました。
文字列に対しては ES2022 Intl.Segmenter
を使うと書記素クラスタや単語などの単位で分割することが出来ます。つまり今となってはコードポイント単位で分割するのが特に標準的な分割というわけではないのにも関わらず String.prototype[Symbol.iterator]
メソッドではそのように分割してしまっています。
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
for (const { segment } of segmenter.segment("👍🏻👍🏼👍🏽👍🏾👍🏿")) {
console.log(segment); // "👍🏻", "👍🏼", "👍🏽", "👍🏾", "👍🏿"
}
また文字列をコードポイントで分割して取り出したいとしても、文字列自身をイテラブルとして使うのではなく、それをラップしたイテラブルオブジェクトを作る方が扱いやすいです。ES2025 Iterator Helpers の Iterator.from
を使ってオブジェクトイテラブルを作ること出来るため[1][2]、今後はこちらを使う方が良いでしょう。
for (const char of Iterator.from("abcde")) {
console.log(char); // "a", "b", "c", "d", "e"
}
他のプリミティブに対してもプロトタイプ汚染しなければならないため、プリミティブをそのままイテレートするのは辞めたほうが良いです。
プリミティブイテラブルを退ける規約制定
プリミティブイテラブルの反省から、今後イテラブルを受け取る機能を追加するときには最初にオブジェクトかどうかのバリデーションを追加することが規約として[3]承認されました。
新しい API が追加されるときには(Iterator.from
のような明示的な変換機能以外は)原則としてオブジェクトイテラブルしか受け取れないようになります。
後方互換性の観点から、今すでにプリミティブイテラブルを許容している仕様に対して Normative Changes を入れることは叶いません。それらは全てドキュメントで列挙されています。列挙されているものは以下のとおりです。
- 配列形式の分割代入
- 配列や引数のスプレット構文
- for...of
yield *
-
Set
とAggregateError
コンストラクタ -
Object.groupBy
,Map.groupBy
-
Promise.all
,Promise.allSettled
,Promise.any
,Promise.race
-
Array.from
,%TypedArray%.from
,Iterator.from
関連する Web 標準仕様
Web IDL にて非同期イテラブルに変換する async iterable<T>
が議論中で、オブジェクトのみを許容することになりそうです。
この async iterable<T>
は今後 ReadableStream.from
と Observable.from
で使われることが想定されます。なお Observable
について記事があるので参考にしてください。
さて、ReadableStream.from
の提案を進めている Deno Land 社の Luca さんが Deno においてプリミティブを渡すと TypeError
を投げる実装を入れました。それを見て、実行時だけでなく型でもエラーを出す PR を出して取り込んでもらいました。
ただこれは変換機能なこともあって今後撤回されるかもしれません(正確には Web IDL で string or async iterable<any>
型になるかもしれない)。
Specifically the
async iterable
webidl type will not directly accept string primitives. However we might have decided something else forReadableStream.from
. I will have to check again.
Discussion