Update 2022-12-15: New section “How will this proposal affect future JavaScript APIs?”
In this blog post, we look at the ECMAScript proposal “Iterator helpers” by Gus Caplan, Michael Ficarra, Adam Vandolder, Jason Orendorff, Kevin Gibbons, and Yulia Startsev. It introduces utility methods for working with iterable data: .map()
, .filter()
, .take()
, etc.
The style of the proposed API clashes with the style of the current iteration API. We’ll explore how we can fix that.
JavaScript supports two kinds of iteration modes:
We’ll first explore synchronous iteration in depth and then briefly look at asynchronous iteration – the asynchronous part of the proposal is very similar to the synchronous part.
In JavaScript, there are several constructs that consume data sequentially – one value at a time – for example, the for-of
loop and spreading into Arrays.
A protocol consists of interfaces and rules for using them.
The iteration protocol is used by JavaScript’s sequential data consumers to access their input. Any data structure that implements this protocol can therefore be consumed by them.
The following roles are involved in the synchronous iteration protocol:
Values that support the iteration protocol are called iterables. They return iterators via a method [Symbol.iterator]()
.
We get the iterated values by repeatedly invoking method .next()
of the iterator returned by the iterable.
These are the TypeScript types for these roles:
interface Iterable<T> {
[Symbol.iterator]() : Iterator<T>;
}
interface Iterator<T> {
next() : IteratorResult<T>;
}
interface IteratorResult<T> {
done: boolean;
value?: T;
}
The Iterator
method .next()
returns:
{done: false, value: x}
for each iterated value x
.{done: true}
after the last iterated value.In other words: We call .next()
until it returns an object whose property .done
is true
.
As an example, let’s use the iteration protocol to access the elements of a Set:
const iterable = new Set(['hello', 'world']);
const iterator = iterable[Symbol.iterator]();
assert.deepEqual(
iterator.next(),
{ value: 'hello', done: false }
);
assert.deepEqual(
iterator.next(),
{ value: 'world', done: false }
);
assert.deepEqual(
iterator.next(),
{ value: undefined, done: true }
);
Since Sets are iterable, we can use them with iteration-based data consumers such as spreading into Arrays (line A) and for-of
loops (line B):
const iterable = new Set(['hello', 'world']);
assert.deepEqual(
[...iterable], // (A)
['hello', 'world']
);
for (const x of iterable) {
console.log(x);
}
// Output:
// hello
// world
Note that we never saw iterators – those are only used internally by the consumers.
The JavaScript standard library has more iteration-based data producers and consumers. We’ll look at these next.
These data structures are iterable:
The following data structures have the methods .keys()
, .values()
, and .entries()
that return iterables that are not Arrays:
Synchronous generator functions and methods expose their yielded values via iterable objects that they return:
/** Synchronous generator function */
function* createSyncIterable() {
yield 'a';
yield 'b';
yield 'c';
}
We’ll use the result of createSyncIterable()
to demonstrate iteration-based data consumers in the next subsection.
All of the following constructs access their input via the iteration protocol.
The for-of
loop:
for (const x of createSyncIterable()) {
console.log(x);
}
// Output:
// a
// b
// c
Spreading:
// Spreading into Arrays
assert.deepEqual(
['>', ...createSyncIterable(), '<'],
['>', 'a', 'b', 'c', '<']
);
// Spreading into function arguments
const arr = [];
arr.push('>', ...createSyncIterable(), '<');
assert.deepEqual(
arr,
['>', 'a', 'b', 'c', '<']
);
Array.from()
:
assert.deepEqual(
Array.from(createSyncIterable()),
['a', 'b', 'c']
);
assert.deepEqual(
Array.from(createSyncIterable(), s => s + s),
['aa', 'bb', 'cc']
);
Array-destructuring:
const [elem0, elem1] = createSyncIterable();
assert.equal(elem0, 'a');
assert.equal(elem1, 'b');
Generators produce iterables, but they can also consume them. That makes them a versatile tool for transforming iterables:
function* map(iterable, callback) {
for (const x of iterable) {
yield callback(x);
}
}
assert.deepEqual(
Array.from(
map([1, 2, 3, 4], x => x ** 2)
),
[1, 4, 9, 16]
);
function* filter(iterable, callback) {
for (const x of iterable) {
if (callback(x)) {
yield x;
}
}
}
assert.deepEqual(
Array.from(
filter([1, 2, 3, 4], x => (x%2) === 0
)),
[2, 4]
);
All of the iterators created by JavaScript’s standard library have a common prototype which the ECMAScript specification calls %IteratorPrototype%
.
We create an Array iterator like this:
const arrayIterator = [][Symbol.iterator]();
This object has a prototype with two properties. Let’s call it ArrayIteratorPrototype
:
const ArrayIteratorPrototype = Object.getPrototypeOf(arrayIterator);
assert.deepEqual(
Reflect.ownKeys(ArrayIteratorPrototype),
[ 'next', Symbol.toStringTag ]
);
assert.equal(
ArrayIteratorPrototype[Symbol.toStringTag],
'Array Iterator'
);
The prototype of ArrayIteratorPrototype
is %IteratorPrototype%
. This object has a method whose key is Symbol.iterator
. Therefore, all built-in iterators are iterable.
const IteratorPrototype = Object.getPrototypeOf(ArrayIteratorPrototype);
assert.deepEqual(
Reflect.ownKeys(IteratorPrototype),
[ Symbol.iterator ]
);
The prototype of IteratorPrototype
is Object.prototype
.
assert.equal(
Object.getPrototypeOf(IteratorPrototype) === Object.prototype,
true
);
This is a diagram for this chain of prototypes:
Roughly, a generator object is an iterator for the values yielded by a generator function genFunc()
. We create it by calling genFunc()
:
function* genFunc() {}
const genObj = genFunc();
The prototype of genObj
is genFunc.prototype
:
assert.equal(
Object.getPrototypeOf(genObj) === genFunc.prototype,
true
);
assert.deepEqual(
Reflect.ownKeys(genFunc.prototype),
[]
);
The prototype of genFunc.prototype
is an object that is shared with all generator objects. In addition to the iterator method .next()
, it has generator-specific methods such as .return()
and .throw()
. The ECMAScript specification calls it %GeneratorFunction.prototype.prototype%
:
const GeneratorFunction_prototype_prototype =
Object.getPrototypeOf(genFunc.prototype);
assert.deepEqual(
Reflect.ownKeys(GeneratorFunction_prototype_prototype),
[
'constructor',
'next',
'return',
'throw',
Symbol.toStringTag,
]
);
assert.equal(
GeneratorFunction_prototype_prototype[Symbol.toStringTag],
'Generator'
);
The prototype of %GeneratorFunction.prototype.prototype%
is %IteratorPrototype%
:
const p = Object.getPrototypeOf;
const IteratorPrototype = p(p([][Symbol.iterator]()));
assert.equal(
Object.getPrototypeOf(GeneratorFunction_prototype_prototype),
IteratorPrototype
);
As we have seen, generator objects are, at their cores, iterators (they have a method .next()
), not iterables. However, we’d also like to use generators to implement iterables. That’s why generator objects have a method [Symbol.iterator]()
that returns this
. They inherit this method from %IteratorPrototype%
.
The following code demonstrates that each generator object returns itself when it is asked for an iterator:
function* gen() {}
const genObj = gen();
assert.equal(
genObj[Symbol.iterator](),
genObj
);
Alas, iterable iterators mean that there are two kinds of iterables:
Iterable iterators are one-time iterables: They always return the same iterator when [Symbol.iterator]()
is called (iteration continues).
Arrays, Sets, etc. are many-times iterables: They always return fresh iterators (iteration restarts).
const iterOnce = ['a', 'b', 'c'].values();
assert.deepEqual(
[...iterOnce, ...iterOnce, ...iterOnce],
['a', 'b', 'c']
);
const iterMany = ['a', 'b', 'c'];
assert.deepEqual(
[...iterMany, ...iterMany, ...iterMany],
['a','b','c', 'a','b','c', 'a','b','c']
);
We have already seen that %IteratorPrototype%
is the prototype of all built-in iterators. The proposal introduces a class Iterator
:
Iterator.from()
is a utility method that we’ll explore soon.Iterator.prototype
refers to %IteratorPrototype%
.%IteratorPrototype%.constructor
refers to Iterator
.Iterator.prototype
contains various methods that are inherited by iterators – for example:
Iterator.prototype.map(mapFn)
returns a mapped version of this
Iterator.prototype.take(limit)
returns an iterator for the first limit
values of this
.Iterator.prototype.toArray()
returns an Array with the values of this
.Iterator.from()
: creating API iterators The static method Iterator.from(x)
returns an instanceof Iterator
:
x
is a synchronous API iterable, it returns x[Symbol.iterator]()
.x
is a synchronous API iterator, it returns x
unchanged.x
is a synchronous legacy iterator (that doesn’t support the new API), it wraps it so that it supports the new API and returns the result.x
is a synchronous legacy iterable, it wraps the result of x[Symbol.iterator]()
and returns it.In the following example, we use Iterator.from()
to convert a legacy iterator to an API iterator:
// Not an instance of `Iterator`
const legacyIterator = {
next() {
return { done: false, value: '#' };
}
};
assert.equal(
Iterator.from(legacyIterator) instanceof Iterator,
true
);
assert.deepEqual(
Iterator.from(legacyIterator).take(3).toArray(),
['#', '#', '#']
);
Iterator.prototype
methods The following subsections give an overview of the new Iterator.prototype
methods. They will use this function to create a synchronous iterable:
function* createSyncIterator() {
yield 'a'; yield 'b'; yield 'c'; yield 'd';
}
Some of the iterator methods keep a counter for the iterated values and pass it on to their callbacks:
.every()
.filter()
.find()
.flatMap()
.forEach()
.map()
.reduce()
.some()
iterator.take(limit)
This method returns an iterator with the first limit
values of iterator
.
Type signature:
Iterator<T>.prototype.take(limit: number): Iterator<T>
Example:
assert.deepEqual(
createSyncIterator().take(1).toArray(),
['a']
);
iterator.drop(limit)
This method returns an iterator that with all values of iterator
, except for the first limit
ones. That is, iteration starts when the iteration counter is limit
.
Type signature:
Iterator<T>.prototype.drop(limit: number): Iterator<T>
Example:
assert.deepEqual(
createSyncIterator().drop(1).toArray(),
['b', 'c', 'd']
);
iterator.filter(filterFn)
This method returns an iterator whose values are the values of iterator
for which filterFn
returns true
.
Type signature:
Iterator<T>.prototype.filter(
filterFn: (value: T, counter: number) => boolean
): Iterator<T>
Example:
assert.deepEqual(
createSyncIterator().filter(x => x <= 'b').toArray(),
['a', 'b']
);
iterator.map(mapFn)
This method returns an iterator whose values are the result of applying mapFn
to the values of iterator
.
Type signature:
Iterator<T>.prototype.map<U>(
mapFn: (value: T, counter: number) => U
): Iterator<U>
Example:
assert.deepEqual(
createSyncIterator().map(x => x + x).toArray(),
['aa', 'bb', 'cc', 'dd']
);
iterator.flatMap(mapFn)
This method returns an iterator whose values are the values of the iterables or iterators that are the results of applying mapFn
to the values of iterator
.
Type signature (simplified):
Iterator<T>.prototype.flatMap<U>(
mapFn: (value: T, counter: number) => Iterable<U> | Iterator<U>
): Iterator<U>
Example:
assert.deepEqual(
createSyncIterator()
.flatMap((value, counter) => new Array(counter).fill(value))
.toArray(),
['b', 'c', 'c', 'd', 'd', 'd']
);
For more information on .flatMap()
, see the section on the related Array method in “JavaScript for impatient programmers”.
iterator.some(fn)
This method returns true
if fn
returns true
for at least one value of iterator
. Otherwise, it returns false
.
Type signature:
Iterator<T>.prototype.some(
fn: (value: T, counter: number) => boolean
): boolean
Example:
assert.equal(
createSyncIterator().some(x => x === 'c'),
true
);
iterator.every(fn)
This method returns true
if fn
returns true
for every value of iterator
. Otherwise, it returns false
.
Type signature:
Iterator<T>.prototype.every(
fn: (value: T, counter: number) => boolean
): boolean
Example:
assert.equal(
createSyncIterator().every(x => x === 'c'),
false
);
iterator.reduce(reducer, initialValue?)
This method uses the function reducer
to combine the values of iterator
into a single value.
Type signature:
Iterator<T>.prototype.reduce<U>(
reducer: (accumulator: U, value: T, counter: number) => U,
initialValue?: U
): U
Example – concatenating the strings of an iterator:
assert.deepEqual(
createSyncIterator().reduce((acc, v) => acc + v),
'abcd'
);
Example – computing the minimum of a Set of numbers:
const set = new Set([3, -2, -5, 4]);
assert.equal(
set.values().reduce((min, cur) => cur < min ? cur : min, Infinity),
-5
);
For more information on .reduce()
, see the section on the related Array method in “JavaScript for impatient programmers”.
iterator.find(fn)
This method returns the first value of iterator
for which fn
returns true
. If there is no such value, it returns undefined
.
Type signature:
Iterator<T>.prototype.find(
fn: (value: T, counter: number) => boolean
): T
Example:
assert.equal(
createSyncIterator().find((_, counter) => counter === 1),
'b'
);
iterator.forEach(fn)
This method applies fn
to each value in iterator
.
Type signature:
Iterator<T>.prototype.forEach(
fn: (value: T, counter: number) => void
): void
Example:
const result = [];
createSyncIterator().forEach(x => result.unshift(x))
assert.deepEqual(
result,
['d', 'c', 'b', 'a']
);
iterator.toArray()
This method returns the values of iterator
in an Array.
Type signature:
Iterator<T>.prototype.toArray(): Array<T>
Example:
assert.deepEqual(
createSyncIterator().toArray(),
['a', 'b', 'c', 'd']
);
iterator.toAsync()
This method returns an asynchronous iterator for the values of the synchronous iterator
.
Type signature:
Iterator<T>.prototype.toAsync(): AsyncIterator<T>
Example:
assert.equal(
createSyncIterator() instanceof AsyncIterator,
false
);
assert.equal(
createSyncIterator().toAsync() instanceof AsyncIterator,
true
);
All built-in iterables automatically support the new API because their iterators already have Iterator.prototype
as a prototype (and are therefore instances of Iterator
).
However, that’s not the case for many iterables in libraries and user code.
This is an example of a manually implemented iterable:
class MyIterable {
#values;
#index = 0;
constructor(...values) {
this.#values = values;
}
[Symbol.iterator]() {
return {
// Arrow function so that we can use the outer `this`
next: () => {
if (this.#index >= this.#values.length) {
return {done: true};
}
const value = this.#values[this.#index];
this.#index++;
return {done: false, value};
},
};
}
}
assert.deepEqual(
Array.from(new MyIterable('a', 'b', 'c')),
['a', 'b', 'c']
);
This iterable does not support the new API. We can use Iterator.from()
to convert an instance of MyIterable
to an API iterator:
const legacyIterable = new MyIterable('a', 'b', 'c');
assert.deepEqual(
Iterator.from(legacyIterable).take(2).toArray(),
['a', 'b']
);
If we want MyIterable
to support the new API, we have to make its iterators instances of Iterator
:
class MyIterable {
// ···
[Symbol.iterator]() {
return {
__proto__: Iterator.prototype,
next: () => {
// ···
},
};
}
}
This is another option:
class MyIterable {
// ···
[Symbol.iterator]() {
return Iterator.from({
next: () => {
// ···
},
});
}
}
The iterables provided by the library Immutable.js don’t support the new API, either. Their iterators are currently implemented like this (source):
class Iterator {
constructor(next) {
this.next = next;
}
toString() {
return '[Iterator]';
}
[Symbol.iterator]() {
return this;
}
// ···
}
To support the new API, class Iterator
has to be renamed and extend the API’s class Iterator
:
class CustomIterator extends Iterator {
// ···
}
We can also use Iterator.from()
to convert Immutable.js iterables to API iterators.
Before the new API, JavaScript’s iteration had an iterable style:
The new API has an iterator style:
I see two ways in which we can fix this clash of styles.
This fix works as follows:
Iterator.from()
wraps iterables and starts API method chains.Iteration value | Accessing API methods |
---|---|
Legacy iterable | Iterator.from(x).take(2) |
Legacy iterator | Never encountered |
Non-iterator iterable (new API) | Iterator.from(str).take(2) |
Iterable iterator (new API) | Iterator.from(map.keys()).take(2) |
Pros and cons:
Pro: compatible with the status quo
Con: relatively verbose
Con: The illusion of only working with iterables is broken whenever an iterator method has parameters that are iterators. For example, we may get method .zip()
in the future:
Iterator.from(arr1).zip(
Iterator.from(arr2),
Iterator.from(arr3),
)
Con: The name Iterator
doesn’t help, either.
Iterator.from()
means getting a “proper” iterator from a data structure that:
Iteration value | Accessing API methods |
---|---|
Iterable (legacy) | Iterator.from(iterable).take(2) |
Iterator (legacy) | Never encountered |
Non-iterator iterable (new API) | Invoke method to create API iterator: |
arr.values().take(2) |
|
map.entries().take(2) |
|
Rare exception (*): | |
Iterator.from(str).take(2) |
|
Iterable iterator (new API) | arr.keys().take(2) |
(*) Strings need a method for creating iterators that is more convenient than [Symbol.iterator]()
.
What does that mean for new JavaScript code?
Functions and methods should accept iterators, not iterables – especially in TypeScript.
If we return a value that supports the iteration protocol, it should be an iterator, not an iterable. This iterator must be an instance of Iterator
.
We don’t make data structures iterable anymore, we implement methods that return instances of Iterator
.
.keys()
, .values()
, .entries()
.iterator()
could also work (due to Symbol.iterator
).The following code illustrates iterator-only style:
// Old: iterate over iterable
for (const element of myArray) {}
// New: iterate over iterator
for (const element of myArray.values()) {}
// Old and new
for (const [index, value] of myArray.entries()) {}
// Old: data structure is iterable
for (const element of myDataStructure) {}
// New: data structure has a method that returns an iterator
for (const element of myDataStructure.values()) {}
// Old: accept iterables
function logData1(iterable) { /*···*/ }
// New: accept iterators
function logData2(iterator) { /*···*/ }
Pros and cons:
Assuming we all agree on iterator style:
It looks like upcoming ECMAScript APIs will switch to iterators – for example: The proposed new Set methods have a parameter other
and require method other.keys()
to return an iterator, not an iterable.
Strings need a method for creating iterators that is more convenient than [Symbol.iterator]()
. Maybe: .toCodePoints()
.
APIs will have to decide what they mean if they require a parameter iter
to be “an iterator”:
iter.next()
exists (“core iterator”) oriter
is an instance of Iterator
?For method .keys()
mentioned in the previous item, the former approach was chosen.
Consequences:
for-of
and for-await-of
accepting core iterators. But maybe only ECMAScript APIs will accept core iterators and non-built-in APIs will only accept instances of Iterator
.CoreIterator
that only has method .next()
.In TypeScript, interface IterableIterator
may not be needed anymore. It is currently the return type of methods such as .keys()
(of Arrays, Maps, etc.), so that their results are accepted by language constructs that require their operands to be iterable. However, with the ECMAScript proposal, every Iterator
is iterable.
The asynchronous version of the iterator method API is similar to the synchronous version but uses asynchronous iteration instead of synchronous iteration.
AsyncIterator.from()
The static method AsyncIterator.from(x)
returns an instance of AsyncIterator
:
x
is an asynchronous API iterable, it returns x[Symbol.asyncIterator]()
.x
is an asynchronous API iterator, it returns x
unchanged.x
is an asynchronous legacy iterator (that doesn’t support the new API), it wraps it so that it supports the new API and returns the result.x
is an asynchronous legacy iterable, it wraps the result of x[Symbol.iterator]()
and returns it.x
is a synchronous iterable (API or legacy), it returns an asynchronous iterator for its values.The API for asynchronous iterators provides asynchronous analogs of the synchronous iterator methods that return iterators – for example:
assert.deepEqual(
await arrayFromAsync(
createAsyncIterator().filter(x => x <= 'b')
),
['a', 'b']
);
assert.deepEqual(
await arrayFromAsync(
createAsyncIterator().map(x => x + x)
),
['aa', 'bb', 'cc', 'dd']
);
We used these helper functions:
async function* createAsyncIterator() {
yield 'a'; yield 'b'; yield 'c'; yield 'd';
}
async function arrayFromAsync(asyncIterator) {
const result = [];
for await (const value of asyncIterator) {
result.push(value);
}
return result;
}
As an aside: Array.fromAsync()
is an ECMAScript proposal.
AsyncIterator.prototype.flatMap()
The asynchronous iterator method .flatMap()
is the only case where not only the return type changes, but also the type of the parameter. Its type signature is:
AsyncIterator<T>.prototype.flatMap<U>(
mapFn: (
value: T, counter: number
) => Iterable<U> | Iterator<U> | AsyncIterable<U> | AsyncIterator<U>
): AsyncIterator<U>
In other words: The callback mapFn
can return iterables or iterators that are either synchronous or asynchronous.
If a synchronous iterator method returns non-iterator values, then its asynchronous version returns Promises for these values. That’s why we use await
in line A, B, C and D:
async function* createAsyncIterator() {
yield 'a'; yield 'b'; yield 'c'; yield 'd';
}
assert.deepEqual(
await createAsyncIterator().toArray(), // (A)
['a', 'b', 'c', 'd']
);
assert.deepEqual(
await createAsyncIterator().reduce((acc, v) => acc + v), // (B)
'abcd'
);
assert.equal(
await createAsyncIterator().some(x => x === 'c'), // (C)
true
);
assert.equal(
await createAsyncIterator().find(x => x === 'c'), // (D)
'c'
);
For looping over asynchronous iterators we can use .forEach()
and will often await the empty Promise it returns:
await createAsyncIterator().forEach(
x => console.log(x)
);
console.log('DONE');
// Output:
// a
// b
// c
// d
// DONE
We can also use for-await-of
:
for await (const x of createAsyncIterator()) {
console.log(x);
}
console.log('DONE');
core-js now supports the stage 3 version of the proposal, via core-js/proposals/iterator-helpers-stage-3
.
I have written a simple polyfill.
With the new iterator methods, any data structure that supports iteration gains more operations. For example, Sets don’t support the operations filter
and map
. Thanks to the new iterator methods, they now do:
assert.deepEqual(
new Set( // (A)
new Set([-5, 2, 6, -3]).values().filter(x => x >= 0)
),
new Set([2, 6])
);
assert.deepEqual(
new Set( // (B)
new Set([-5, 2, 6, -3]).values().map(x => x / 2)
),
new Set([-2.5, 1, 3, -1.5])
);
Note that new Set()
accepts iterables and therefore iterable iterators (line A and line B).
One important benefit of iteration is that data consumers that also produce iterated data, process data incrementally. As an example, consider code that reads a text file, puts the string '> '
before each line and logs the result.
If we use an Array, we have to read the whole file before we can log the first line.
readFileSync('data.txt') // hypothetical
.split(/\r?\n/)
.map(line => '> ' + line)
.forEach(line => console.log(line))
;
If we use iteration, we can log the first line shortly after reading it. With the proposed new API, this could look like this:
createReadableStream('data.txt') // hypothetical
.pipeThrough(new ChunksToLinesStream())
[Symbol.asyncIterator]()
.map(line => '> ' + line) // new API
.forEach(line => console.log(line)) // new API
;
We have had generators for this kind of incremental processing for a while. Now we also have the iterator methods.