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, 1

A 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 3

The 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); // 2

Count 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.

HelperLazy?Returns
map(fn)yesan iterator
filter(fn)yesan iterator
take(n) / drop(n)yesan iterator
flatMap(fn)yesan iterator
toArray()no, it pulls everythingan array
reduce(fn, init)no, it pulls everythingone value
some / every / findno, but they short-circuita 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 the 1, so spreading picks up at 2. Arrays are reusable because each spread asks the array for a brand new iterator.
Why does naturals().map(f).take(3).toArray() terminate even though naturals() 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. toArray does the pulling and take(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], so for...of and spread-into-array both throw. Strings, Sets and NodeLists all implement the protocol. Use Object.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 has next() returning { value, done }.
  • One protocol powers for...of, spread, destructuring and Array.from.
  • Iterators are one-shot; iterables hand out a fresh one per walk.
  • Iterator helpers are lazy, so take makes infinite sequences practical and skips work nobody asked for.