Engineering Notes
Home/Languages/JavaScript/Iterators and Generators
Languages · JavaScript

Iterators and Generators

An object is iterable if it implements a method at the special key Symbol.iterator that returns an iterator — an object with a .next() method returning { value, done }.

💻 Languages● Intermediate⏱ 8 min read🛠️ 4 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • What Makes Something "Iterable"
  • Built-in Iterables
  • Building a Custom Iterable
  • Iterable vs Array-Like
  • What a Generator Is
Part 1The Iterator Protocol

1What Makes Something "Iterable"

An object is iterable if it implements a method at the special key Symbol.iterator that returns an iterator — an object with a .next() method returning { value, done }.

js
const arr = [10, 20, 30];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 10, done: false }
console.log(iterator.next()); // { value: 20, done: false }
console.log(iterator.next()); // { value: 30, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

This is the exact mechanism for...of, spread (...), and destructuring all rely on internally — anything implementing this protocol works with all three, automatically.

for...of calls .next() on an iterator object repeatedly, reading {value, done} until done is true
for...of calls .next() on an iterator object repeatedly, reading {value, done} until done is true

2Built-in Iterables

TypeIterable?
Array✅
String✅ (iterates by Unicode code point, not raw UTF-16 code unit)
Map✅ (iterates [key, value] pairs)
Set✅ (iterates values)
Plain object {}❌ not iterable by default
arguments object✅
NodeList (browser DOM)✅
js
for (const char of "hi") console.log(char);       // "h", "i"
for (const [k, v] of new Map([["a", 1]])) console.log(k, v); // "a" 1
// for (const x of { a: 1 }) {}  // TypeError: {a:1} is not iterable

3Building a Custom Iterable

Implementing Symbol.iterator on your own object makes it work with for...of, spread, and destructuring:

js
class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  }
}

for (const n of new Range(1, 5)) console.log(n); // 1, 2, 3, 4, 5
console.log([...new Range(1, 3)]);                  // [1, 2, 3] — spread works too
const [first, second] = new Range(1, 5);            // destructuring works too

4Iterable vs Array-Like

An array-like object has a length and numeric indices, but no Symbol.iterator — for...of and spread do not work on it directly.

js
function example() {
  console.log(typeof arguments[Symbol.iterator]); // "function" — arguments IS iterable
}

const fakeArrayLike = { 0: "a", 1: "b", length: 2 }; // NOT iterable
// [...fakeArrayLike]; // TypeError
Array.from(fakeArrayLike); // ["a", "b"] — Array.from handles array-likes AND iterables

Array.from is the standard bridge for converting either array-likes or iterables into a real array.

⚠️ Common Pitfalls

  1. Assuming all objects with length are iterable — array-likes need Array.from or manual conversion; they don't work with for...of/spread directly.
  2. Manually calling .next() and forgetting to check .done — reading .value after done: true gives undefined, easy to misinterpret as a real value of undefined.
  3. Re-using a "used up" iterator — once an iterator reaches done: true, calling .next() again typically keeps returning done: true; get a fresh iterator (or a fresh for...of pass over the iterable) to iterate again.
Part 2Generator Functions

1What a Generator Is

A generator function (function*) is a function that can pause and resume its own execution, automatically implementing the iterator protocol for you — no manual { value, done } objects required.

js
function* range(start, end) {
  for (let i = start; i <= end; i++) {
    yield i; // pause here, hand `i` out, wait to be resumed
  }
}

const gen = range(1, 3);
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }

for (const n of range(1, 3)) console.log(n); // 1, 2, 3 — generators ARE iterables

Calling a generator function does not run its body immediately — it returns a generator object. Execution only proceeds up to the next yield each time .next() is called.

2Rewriting the Custom Range Class from Part 1

js
class Range {
  constructor(start, end) { this.start = start; this.end = end; }
  *[Symbol.iterator]() {   // generator method — no manual next()/done object needed
    for (let i = this.start; i <= this.end; i++) yield i;
  }
}
for (const n of new Range(1, 3)) console.log(n); // 1, 2, 3

Generators are the standard, much simpler way to implement Symbol.iterator compared to hand-writing the protocol from Part 1.

3yield Can Receive Values Back

js
function* echo() {
  while (true) {
    const received = yield "waiting for input";
    console.log("got:", received);
  }
}
const gen = echo();
gen.next();          // starts the generator, runs to the first yield
gen.next("hello");   // resumes, `received` becomes "hello", logs "got: hello"

The argument passed to .next(value) becomes the result of the yield expression that was paused — a two-way communication channel between caller and generator.

4yield* — Delegating to Another Iterable

js
function* inner() { yield 1; yield 2; }
function* outer() {
  yield 0;
  yield* inner(); // delegates — yields everything inner() yields, in order
  yield 3;
}
console.log([...outer()]); // [0, 1, 2, 3]

5Infinite Sequences (Lazy Evaluation)

Generators only compute values as they're requested — this makes infinite sequences practical:

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

function take(iterable, count) {
  const result = [];
  for (const value of iterable) {
    if (result.length >= count) break;
    result.push(value);
  }
  return result;
}

take(naturalNumbers(), 5); // [1, 2, 3, 4, 5] — never actually generates beyond what's needed

A plain array could never represent this — it would need infinite memory. A generator computes values lazily, one at a time.

6Generators for Error Handling

js
function* worker() {
  try {
    yield 1;
    yield 2;
  } catch (e) {
    console.log("caught inside generator:", e.message);
  }
}
const g = worker();
g.next();
g.throw(new Error("stop")); // injects an error at the paused yield point
Under the HoodGenerators Are Real Suspended Execution Frames▾

A normal function's stack frame (see the Call Stack topic) is destroyed the instant it returns — its local variables simply cease to exist. A generator fundamentally cannot work this way, since it must resume exactly where it left off, with all of its local state intact, potentially long after the original call to the generator function.

The frame moves to the heap, not the stack

When you call a generator function, the engine does not run it on the normal call stack at all. Instead, it creates a generator object on the heap that holds a complete, suspendable execution frame: the current instruction pointer (exactly which point in the function body execution has paused at), every local variable's current value, and enough state to resume the underlying bytecode execution from that exact point.

js
function* counter() {
  let count = 0;         // this local variable lives inside the generator's HEAP-allocated frame,
  while (true) {           // not a normal stack frame — it survives between .next() calls
    count += 1;
    yield count;
  }
}

Each call to .next() briefly pushes the generator's saved frame back onto the call stack, runs until the next yield (or return, or the function ends), then pops it back off and stores the updated state back in the heap object — repeating this hand-off is what makes pause/resume possible at all.

This is why generators have closure-like memory implications

A paused generator — one that has been .next()-ed at least once but hasn't finished — keeps its entire local state reachable for as long as anything holds a reference to the generator object, exactly like an active closure's Context (see the Closures topic). An infinite generator that's created but never fully consumed, and never garbage collected because something still references it, keeps its (possibly growing) internal state alive indefinitely — the practical cost of the laziness described in section 5.

The pre-native implementation: a state machine

Before engines implemented generators natively, tools like Babel's "regenerator" transform simulated this same pause/resume behavior using ordinary functions: the generator body was compiled into a single function containing a loop and a switch statement, where each case represented the code between two yield points, and a saved "which case am I on" variable played the role of the instruction pointer. Understanding this transform is a useful mental model even now: a generator is conceptually "a function that remembers which case to jump into next," whether that's implemented via a literal switch statement (transpiled) or a native suspended frame (modern engines) — same idea, different execution layer.

⚠️ Common Pitfalls

  1. Forgetting generators are lazy — nothing runs until .next() is called; a generator function's body does not execute at all just by calling it.
  2. Infinite generators without a break condition in the consumer — for...of over naturalNumbers() with no break will loop forever.
  3. Reusing an exhausted generator — once done, a generator object cannot be restarted; call the generator function again to get a fresh one.
  4. Confusing yield with return — return value inside a generator sets { value, done: true } on that final .next() call and ends iteration; for...of does NOT include this final returned value in its iteration (only done: false values are yielded to for...of).

📋 Quick Reference

text
function* gen() { yield x; }         → generator function
gen().next()                          → { value, done }
yield* otherIterable                  → delegate to another iterable
for (const v of gen()) {}             → generators are iterables

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is the iteration protocol?core▾

An object is iterable if it has [Symbol.iterator]() returning an iterator with next() that returns { value, done }. for…of, spread, destructuring and Array.from use it.

Follow-ups interviewers ask
  • Which built-ins are iterable? Arrays, strings, Maps, Sets, arguments, NodeLists.
Q2What are generators?core▾

Functions declared with function* that can pause at yield and resume later, returning an iterator. They make lazy sequences and custom iterables easy.

Follow-ups interviewers ask
  • Infinite sequence example? function* ids() { let i = 0; while (true) yield i++; }.
Q3What are async iterators and for await?core▾

Iterators whose next() returns promises; for await (const x of source) consumes them. Used for streams, paginated APIs and event sources. async function* creates them.

Follow-ups interviewers ask
  • Node streams? Readable streams are async iterable.
Q4How can generators implement lazy pipelines?senior▾

Chain generator functions (take(filter(map(source)))) so items are processed one at a time on demand, without intermediate arrays, enabling infinite or very large sequences.

Follow-ups interviewers ask
  • Iterator helpers? Native .map/.filter/.take on iterators.
Q5What are return() and throw() on iterators?core▾

return() ends iteration early (called automatically by break in for…of, letting generators clean up in finally); throw() injects an error at the paused yield.

Follow-ups interviewers ask
  • Why does it matter? Resource cleanup when consumers stop early.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.