JavaScript Iterators and Generators: Building Lazy Sequences

Link copied
JavaScript Iterators and Generators: Building Lazy Sequences

JavaScript Iterators and Generators: Building Lazy Sequences

Most JavaScript developers know for...of works on arrays. Far fewer know what makes it work — the iterator protocol — and even fewer have used generators (function*) to build their own. Together these two features let you write lazy sequences, infinite ranges, and stream processors in plain JavaScript with no library help.

This is Part 15 of the JavaScript Fundamentals series. Part 14 on Symbols is required reading — the iterator protocol is built on Symbol.iterator.

The iterator protocol #

An iterator is any object with a next() method that returns { value, done }:

const iterator = {
  i: 0,
  next() {
    if (this.i < 3) return { value: this.i++, done: false };
    return { value: undefined, done: true };
  },
};

iterator.next(); // { value: 0, done: false }
iterator.next(); // { value: 1, done: false }
iterator.next(); // { value: 2, done: false }
iterator.next(); // { value: undefined, done: true }

That's the entire protocol. The iterator yields values until done: true, then it's exhausted.

An iterable is any object with a [Symbol.iterator]() method that returns an iterator:

const countTo3 = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        return i < 3 ? { value: i++, done: false } : { done: true };
      },
    };
  },
};

for (const n of countTo3) console.log(n); // 0, 1, 2
[...countTo3];                            // [0, 1, 2]
Array.from(countTo3);                     // [0, 1, 2]

Every built-in iterable (arrays, strings, Maps, Sets, NodeLists) implements this. for...of, spread, destructuring, and Array.from all call [Symbol.iterator]() first, then next() until done.

Generators — function* syntax #

Writing iterators by hand is verbose. Generators are syntactic sugar that builds an iterator from a function body using yield:

function* countTo3() {
  yield 0;
  yield 1;
  yield 2;
}

const it = countTo3();
it.next(); // { value: 0, done: false }
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: undefined, done: true }

for (const n of countTo3()) console.log(n); // 0, 1, 2
[...countTo3()];                            // [0, 1, 2]

Key behaviors:

  • function* declares a generator function.
  • Calling it returns an iterator without running the body.
  • Each next() runs the body up to the next yield, returns the yielded value, and pauses.
  • The function's local state (variables, position) survives between calls.
  • return (or running off the end) finishes with { done: true }.

Generators implement both the iterator AND iterable protocols (they have their own [Symbol.iterator] returning this), so they work directly in for...of and spread.

Lazy sequences #

Generators only compute values on demand. That makes infinite or expensive sequences cheap:

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

function take(iter, n) {
  const result = [];
  for (const v of iter) {
    if (result.length >= n) break;
    result.push(v);
  }
  return result;
}

take(naturals(), 5); // [1, 2, 3, 4, 5]

naturals() would loop forever if you tried to materialize it. take short-circuits at 5 — generators never advance past what's consumed. You cannot do this with eagerly-built arrays.

Composing generators with yield* #

yield* delegates to another iterable, flattening it into the current sequence:

function* numbers() {
  yield 1;
  yield 2;
  yield* [3, 4, 5];      // flatten an array
  yield* otherGenerator(); // flatten another generator
  yield 6;
}

This composes naturally:

function* range(start, end) {
  for (let i = start; i < end; i++) yield i;
}
function* mapped(iter, fn) {
  for (const v of iter) yield fn(v);
}
function* filtered(iter, pred) {
  for (const v of iter) if (pred(v)) yield v;
}

[...take(filtered(mapped(naturals(), n => n * 2), n => n > 5), 3)];
// → [6, 8, 10]

This is a streaming pipeline. Nothing is materialized as an intermediate array. The work is pulled lazily from the source.

Pattern: Pagination as a generator #

A classic place generators shine — paginated APIs:

async function* fetchUsers() {
  let page = 1;
  while (true) {
    const res = await fetch(`/api/users?page=${page}`).then(r => r.json());
    yield* res.items;          // yield each user from this page
    if (!res.hasMore) return;
    page++;
  }
}

// Consumer doesn't care about pagination:
for await (const user of fetchUsers()) {
  process(user);
  if (user.id === targetId) break; // can stop early — no extra pages fetched
}

Notice async function* and for await...of. Async generators yield Promises; for await unwraps each before continuing. Same protocol, async variant via Symbol.asyncIterator.

Two-way communication #

Generators can also receive values pushed back into them via next(arg):

function* dialog() {
  const name = yield 'What is your name?';
  const age = yield `Hi ${name}, how old?`;
  return `${name} is ${age}.`;
}

const g = dialog();
g.next();          // { value: 'What is your name?', done: false }
g.next('Alice');   // { value: 'Hi Alice, how old?', done: false }
g.next(30);        // { value: 'Alice is 30.', done: true }

Each next(value) replaces the result of the most recent yield expression inside the generator. This was the secret behind libraries like co.js before async/await shipped — generators driven by a runner that fed Promises back as the resolved values.

In 2026, you don't usually write this by hand — async/await is built on the same idea but ergonomic. Generators-with-bidirectional-flow are still useful for state machines and custom schedulers.

return() and throw() — early termination #

Generator iterators have two methods beyond next():

function* gen() {
  try { yield 1; yield 2; yield 3; }
  finally { console.log('cleanup'); }
}

const it = gen();
it.next();          // 1
it.return('done');  // 'cleanup' logged, { value: 'done', done: true }

return() forces the generator to terminate, running any finally blocks first. throw() similarly throws an exception inside the generator at the current yield point.

This is the cleanup mechanism behind for...of early-break:

for (const v of gen()) { break; }
// → 'cleanup' is logged because for...of called return()

Generators reliably clean up resources (close DB cursors, release locks) even when consumers bail out early.

Async iterators and streams #

for await (const chunk of readableStream) {
  process(chunk);
}

Works for: Node.js streams (since 10.x), ReadableStream in browsers, async generators, anything implementing Symbol.asyncIterator.

Writing one:

async function* lineReader(filename) {
  const fileHandle = await fs.open(filename);
  try {
    let buffer = '';
    for await (const chunk of fileHandle.createReadStream({ encoding: 'utf8' })) {
      buffer += chunk;
      const lines = buffer.split('\n');
      buffer = lines.pop(); // hold incomplete last line
      for (const line of lines) yield line;
    }
    if (buffer) yield buffer;
  } finally {
    await fileHandle.close(); // runs even if consumer breaks early
  }
}

for await (const line of lineReader('huge.log')) {
  if (line.includes('ERROR')) console.log(line);
}

Memory-efficient, cancelable, composable. The file is read lazily; cleanup is automatic.

Common gotchas #

Gotcha 1: Iterators are one-shot.

const it = [1, 2, 3][Symbol.iterator]();
[...it]; // [1, 2, 3]
[...it]; // []  — already exhausted

To restart, get a fresh iterator. Arrays themselves are iterables (callable again); their [Symbol.iterator]() result is the one-shot iterator.

Gotcha 2: Generators are NOT arrays.

const g = gen();
g.length;       // undefined
g[0];           // undefined

If you need indexing, materialize first: [...g] or Array.from(g).

Gotcha 3: yield expressions outside a generator are a SyntaxError. You can't use yield in a regular function or arrow function. Must be function*.

Gotcha 4: Arrow generators don't exist. There is no *=> syntax. Generators must use the function* form.

Gotcha 5: for...of over a Map yields entries, not values.

for (const x of new Map([['a', 1]])) {
  console.log(x); // ['a', 1] — entry pair
}

Use .values() or .keys() explicitly if you want one or the other.

Recap #

  • The iterator protocol = an object with next() returning { value, done }.
  • The iterable protocol = an object with [Symbol.iterator]() returning an iterator.
  • Generators (function*) are sugar that builds iterators from a paused function. State survives between next() calls.
  • Lazy sequences — generators never compute past the consumer's demand. Infinite ranges, pagination, and stream processing become trivial.
  • yield* delegates to another iterable, enabling clean composition.
  • Async generators + for await...of extend this to async streams.
  • Iterators are one-shot; iterables can be restarted.

Next up: Pure Functions and Side Effects — the gateway to functional JavaScript, what "pure" actually means, and why mutability is the root of most application bugs.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *