JavaScript Intermediate #3 Iterators and Generators

After #2 Async Intro, this post covers another of JavaScript’s core tools — iterators and generators.

How does for...of work? #

In Basics #3 Control Flow you iterated arrays with for...of.

for...of revisited
for (const x of [1, 2, 3]) {
  console.log(x);
}

It works not just on arrays. JavaScript made it so that any object following a certain contract (the iterable protocol) can be iterated with for...of.

things already iterable
for (const ch of 'hello') {       // string
  console.log(ch);
}

for (const [k, v] of new Map([['a', 1], ['b', 2]])) {   // Map
  console.log(k, v);
}

for (const x of new Set([1, 2, 3])) {   // Set
  console.log(x);
}

Arrays, strings, Map, Set, and NodeList (document.querySelectorAll), etc. are already iterable. They all follow the same contract.

The iterable protocol #

For an object to be iterable, it must have one thing:

There must be a function under the key Symbol.iterator. Calling that function must return an iterator with next(). next() must return a { value, done } object.

Building one is the fastest way to understand it.

building an iterable
const range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  },
};

for (const n of range) {
  console.log(n);
}
// 1, 2, 3, 4, 5

Symbol.iterator is the key. The [...] in the key slot is the computed-property syntax from Basics #5. The special symbol Symbol.iterator becomes the key.

for...of follows this contract internally — calling next() repeatedly until done is true and pulling out values.

Spread and destructuring are iterable too #

Beyond for...of, spread and array destructuring also use the iterable protocol.

iterable usage
const arr = [...range];
// [1, 2, 3, 4, 5]

const [first, second] = range;
// first = 1, second = 2

Math.max(...range);   // 5
Array.from(range);    // [1, 2, 3, 4, 5]

This is powerful — once you make something iterable, every JavaScript sequence tool works on it.

Generators — short syntax for iterables #

The range above works, but the iterable-creation code is long and intricate. JavaScript has syntax that does the same in one shot — generators.

range with a generator
function* range(from, to) {
  for (let n = from; n <= to; n++) {
    yield n;
  }
}

for (const n of range(1, 5)) {
  console.log(n);
}
// 1, 2, 3, 4, 5

[...range(1, 5)];   // [1, 2, 3, 4, 5]

Syntax:

  • function* — a function with an asterisk → generator
  • yield — emit a value and pause
  • On the next call, continue from after yield

Calling a generator function returns an iterable object. for...of pulls values from it.

Lazy sequences — even infinite ones #

A generator’s real strength — it produces only as needed. Even infinite sequences are expressible.

infinite naturals
function* naturals() {
  let n = 1;
  while (true) {
    yield n++;
  }
}

const gen = naturals();
gen.next();   // { value: 1, done: false }
gen.next();   // { value: 2, done: false }
gen.next();   // { value: 3, done: false }
// ... forever

while (true) is an infinite loop, but it doesn’t hang. Each next() only produces the next value. Computed only on demand — the meaning of laziness.

Slicing an infinite sequence #

just the first N
function* take(iterable, n) {
  let count = 0;
  for (const x of iterable) {
    if (count >= n) return;
    yield x;
    count++;
  }
}

const first10 = [...take(naturals(), 10)];
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

This is exactly what the take function commonly found in functional libraries does.

Other generator uses #

1) Tree/graph traversal #

tree traversal
function* walk(node) {
  yield node.value;
  for (const child of node.children ?? []) {
    yield* walk(child);    // delegate to another generator
  }
}

const tree = {
  value: 'root',
  children: [
    { value: 'a', children: [{ value: 'a1' }] },
    { value: 'b' },
  ],
};

for (const v of walk(tree)) {
  console.log(v);
}
// root, a, a1, b

yield* is syntax for passing through every value of another iterable. Expresses recursive traversal cleanly.

2) Step-by-step procedures #

multi-step
function* steps() {
  console.log('step 1 start');
  yield;
  console.log('step 2 start');
  yield;
  console.log('step 3 end');
}

const s = steps();
s.next();   // step 1 start
s.next();   // step 2 start
s.next();   // step 3 end

The caller advances steps explicitly with next(). Sometimes used in tests or simulations.

Async iterators — for await...of #

A variant for iterating async data. Comes up with streaming data (files, fetch response chunks).

for await...of
async function readChunks(response) {
  for await (const chunk of response.body) {
    console.log(chunk);
  }
}

Detailed in #6 fetch API.

What is Symbol? #

Symbol.iterator showed up — Symbol is also one of JavaScript’s primitive types. It’s a value that creates a unique identifier.

Symbol basics
const s1 = Symbol('id');
const s2 = Symbol('id');

s1 === s2;   // false — same description but different symbols

Language-level keys (Symbol.iterator, Symbol.asyncIterator, etc.) are defined as Symbols so they never collide. You won’t create them often in regular code, but libraries use them when attaching metadata to objects without collision.

Wrap-up #

What we covered:

  • for...of / spread / destructuring rest on the iterable protocol
  • To be iterable, return an iterator with next() from Symbol.iterator
  • Build iterables succinctly with generators (function*, yield)
  • Lazy sequences — even infinite sequences are expressible
  • Delegate to another iterable with yield*
  • Iterate async iterables with for await...of
  • Symbol — a unique identifier without collisions

In the next post (#4 Destructuring and spread/rest in depth) we go deeper into destructuring and spread that were touched on lightly in Basics — nested patterns, parameter destructuring, dynamic keys, and other practical patterns.

X