Iterables and Iterator Helpers
Mental model: An iterable is anything that can hand you a next(); an iterator helper builds a pipeline that pulls one value at a time instead of building whole arrays.
Level: advanced · about 13 minutes
const letters = ['a', 'b'];
const it = letters[Symbol.iterator]();
console.log(it.next()); // { value: 'a', done: false }
console.log(it.next()); // { value: 'b', done: false }
console.log(it.next()); // { value: undefined, done: true }What for...of is doing for you, spelled out.
An iterable is any value with a [Symbol.iterator]() method that returns an iterator: an object with a next() that yields { value, done }. Arrays, strings, Set, Map, NodeList, arguments and generators all implement it. That one protocol is what makes for...of, spread, destructuring, Array.from, Promise.all and yield* work on all of them.
const s = new Set([1, 2, 3]);
for (const n of s) console.log(n); // for...of
console.log([...s]); // spread
const [first, ...rest] = s; // destructuring
console.log(first, rest); // 1 [2, 3]
console.log(Array.from('hi')); // strings are iterable too
Make your own iterable
const countdown = {
from: 3,
*[Symbol.iterator]() {
for (let n = this.from; n > 0; n--) yield n;
},
};
console.log([...countdown]); // [3, 2, 1]
for (const n of countdown) console.log(n); // 3, 2, 1A generator function is the least painful way to implement the protocol.
function* twoValues() { yield 1; yield 2; }
const iterator = twoValues();
console.log([...iterator]); // [1, 2]
console.log([...iterator]); // [], already exhausted
console.log([...twoValues()], [...twoValues()]); // [1,2] [1,2], new each time
Iterator helpers: array methods without the arrays
ES2025 put map, filter, take, drop, flatMap, reduce, toArray, forEach, some, every and find on the iterator prototype. The transforming ones are lazy: they return a new iterator and do no work until something pulls a value. That is the difference that matters.
Array chain: three arrays built
const result = bigArray
.map(expensive) // full array
.filter(keep) // full array
.slice(0, 3); // then throw most away
Iterator chain: three values computed
const result = bigArray.values()
.map(expensive) // nothing yet
.filter(keep) // nothing yet
.take(3)
.toArray(); // now it pulls, and stops at 3The array version calls expensive once per element. The iterator version calls it until three values have survived the filter, then stops. Same reading order, very different work.
let calls = 0;
const double = (n) => { calls++; return n * 2; };
const nums = [1, 2, 3, 4, 5, 6, 7, 8];
nums.map(double).slice(0, 2);
console.log('array chain calls:', calls); // 8
calls = 0;
nums.values().map(double).take(2).toArray();
console.log('iterator chain calls:', calls); // 2Count the calls and the laziness stops being theoretical.
const words = ['ant', 'bee', 'cow', 'dog', 'eel'];
console.log(words.values().drop(1).take(2).toArray()); // ['bee', 'cow']
console.log(words.values().filter((w) => w > 'b').map((w) => w.toUpperCase()).toArray());
// ['BEE', 'COW', 'DOG', 'EEL']
console.log(words.values().reduce((acc, w) => acc + w[0], '')); // 'abcde'
console.log(words.values().some((w) => w.length > 3)); // false
Infinite sequences, safely
function* naturals() {
let n = 1;
while (true) yield n++;
}
console.log(naturals().take(5).toArray()); // [1, 2, 3, 4, 5]
console.log(naturals().filter((n) => n % 3 === 0).take(3).toArray()); // [3, 6, 9]
console.log(naturals().map((n) => n * n).drop(2).take(2).toArray()); // [9, 16]
// [...naturals()] would hang forever. Never spread an infinite iterator.This generator never ends, and that is fine as long as you take.
| Helper | Lazy? | Returns |
|---|---|---|
map(fn) | yes | an iterator |
filter(fn) | yes | an iterator |
take(n) / drop(n) | yes | an iterator |
flatMap(fn) | yes | an iterator |
toArray() | no, it pulls everything | an array |
reduce(fn, init) | no, it pulls everything | one value |
some / every / find | no, but they short-circuit | a boolean or a value |
function* gen() { yield 1; yield 2; yield 3; }
const it = gen();
console.log(it.next().value);
console.log([...it]);Calling next() consumes the first value, and spreading continues from where the cursor stopped. An iterator has no rewind, which is the single most important practical difference from an array.
Try it yourself
Laziness you can count
let work = 0;
const heavy = (n) => { work++; return n * n; };
function* naturals() {
let n = 1;
while (true) yield n++;
}
const firstThreeEvenSquares = naturals()
.map(heavy)
.filter((n) => n % 2 === 0)
.take(3)
.toArray();
console.log(firstThreeEvenSquares); // [4, 16, 36]
console.log('heavy() ran', work, 'times');
Increase the take and watch the call count follow it. Then move the filter before the map and count again.
Write your own iterable
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
*[Symbol.iterator]() {
for (let n = this.start; n < this.end; n++) yield n;
}
}
const r = new Range(1, 5);
console.log([...r]); // [1, 2, 3, 4]
console.log([...r]); // [1, 2, 3, 4] again: iterables are reusable
console.log(Math.max(...r)); // 4
const [head, ...tail] = r;
console.log(head, tail); // 1 [2, 3, 4]
Add a step option so the range can count by twos. Then make it lazy enough to survive an end of Infinity plus a take.
Exercises
A range generator
Write a generator function range(start, end, step = 1) that yields numbers from start up to but not including end. It must be reusable, so calling range(1, 4) twice gives a fresh sequence each time, and it must yield nothing when start is already past end.
Check yourself
- What does this log?
- [2, 3] — An iterator is a cursor with no rewind.
next()consumed the1, so spreading picks up at2. Arrays are reusable because each spread asks the array for a brand new iterator. - Why does
naturals().map(f).take(3).toArray()terminate even thoughnaturals()is infinite? take(3)stops pulling after three values, and nothing runs until something pulls — Iterator helpers are lazy: they wrap the source and only compute on demand.toArraydoes the pulling andtake(3)stops answering after three values, so the generator is only ever resumed three times.- Which of these is NOT iterable?
{ a: 1 }— Plain objects have no[Symbol.iterator], sofor...ofand spread-into-array both throw. Strings, Sets and NodeLists all implement the protocol. UseObject.entries(obj)when you need to iterate an object.
Common mistakes
- Spreading an iterator twice and getting an empty array the second time.
- Spreading an infinite generator, which hangs the tab with no error.
- Reaching for iterator helpers on small arrays, where they add indirection and no benefit.
Takeaways
- Iterable means it has
[Symbol.iterator](); iterator means it hasnext()returning{ value, done }. - One protocol powers
for...of, spread, destructuring andArray.from. - Iterators are one-shot; iterables hand out a fresh one per walk.
- Iterator helpers are lazy, so
takemakes infinite sequences practical and skips work nobody asked for.