Skip to content

Symbols, Iterators, and Generators

intermediate12 min read

The Protocol That Powers for...of

Ever wonder what actually happens when you write for (const item of array)? JavaScript doesn't just loop over the array directly. It calls a specific protocol — the iterable protocol — which is defined using a Symbol. Once you understand this protocol, you can make any object iterable, build lazy sequences that process a million items in constant memory, and stream data with async generators.

Mental Model

Think of the iterable protocol as a vending machine contract. Any object can become a vending machine by implementing one method: [Symbol.iterator](). This method returns a dispenser (an iterator) with a next() button. Each press of next() returns { value, done }. When done is true, the machine is empty. for...of, spread, destructuring — they all just press the button repeatedly until done.

Symbols — Unique, Collision-Free Keys

Before we get to iterators, let's talk about the primitive that makes them possible. A Symbol is a unique primitive value. Two symbols are never equal, even if they have the same description:

const a = Symbol("id");
const b = Symbol("id");
a === b; // false — always unique

// Symbols as property keys — no collision with string keys
const obj = {};
obj[a] = "value for a";
obj[b] = "value for b";
obj["id"] = "string key";
// All three coexist without conflict

Well-Known Symbols

The language defines special symbols that control object behavior:

SymbolControls
Symbol.iteratorHow for...of, spread, destructuring work
Symbol.toPrimitiveHow objects convert to primitives
Symbol.hasInstanceHow instanceof works
Symbol.toStringTagWhat Object.prototype.toString returns
Symbol.asyncIteratorHow for await...of works
Symbol.speciesConstructor to use for derived objects
class CustomList {
  constructor(...items) {
    this.items = items;
  }

  // Make instances work with for...of
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => ({
        value: this.items[index],
        done: index++ >= this.items.length
      })
    };
  }

  // Customize instanceof
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance.items);
  }
}

const list = new CustomList("a", "b", "c");
for (const item of list) console.log(item); // "a", "b", "c"
[...list]; // ["a", "b", "c"]
const [first] = list; // "a"

The Iterable Protocol

An object is iterable if it has a [Symbol.iterator] method that returns an iterator. An iterator is any object with a next() method that returns { value, done }.

// Making a range iterable
const range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        return current <= last
          ? { value: current++, done: false }
          : { done: true }; // value is implicitly undefined
      }
    };
  }
};

for (const n of range) console.log(n); // 1, 2, 3, 4, 5
[...range]; // [1, 2, 3, 4, 5]
Common Trap

Strings are iterable, and they iterate over Unicode code points, not UTF-16 code units. This matters for emoji and other characters outside the Basic Multilingual Plane:

const emoji = "Hello ";
emoji.length;       // 8 (UTF-16 code units — the emoji is 2 units)
[...emoji].length;  // 7 (code points — the emoji is 1 code point)

for (const char of emoji) console.log(char);
// "H", "e", "l", "l", "o", " ", "" — correct!

If you use a for loop with index (emoji[i]), you'll get broken surrogate pairs for emoji. Always use for...of or spread for character-by-character string iteration.

Generator Functions — Iterators Made Easy

Writing iterator objects by hand is verbose and error-prone. Turns out there's a much better way — generator functions create them automatically:

function* range(from, to) {
  for (let i = from; i <= to; i++) {
    yield i; // Pause and return this value
  }
}

const iter = range(1, 5);
iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: 4, done: false }
iter.next(); // { value: 5, done: false }
iter.next(); // { value: undefined, done: true }

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

Yield Pauses Execution

The key insight: yield pauses the generator. The function's state (local variables, execution position) is preserved. The next next() call resumes from where it paused:

function* demo() {
  console.log("Start");
  const x = yield "first";   // Pauses here. x gets the value passed to next()
  console.log("Got:", x);
  const y = yield "second";
  console.log("Got:", y);
  return "done";
}

const gen = demo();
gen.next();        // Logs "Start", returns { value: "first", done: false }
gen.next("hello"); // Logs "Got: hello", returns { value: "second", done: false }
gen.next("world"); // Logs "Got: world", returns { value: "done", done: true }
Execution Trace
Create
gen = demo()
Creates generator object but does NOT run the body
next()
Runs until yield 'first'
Logs 'Start', pauses at yield, returns { value: 'first', done: false }
next('hello')
Resumes: x = 'hello'
'hello' becomes the result of yield. Logs 'Got: hello', pauses at next yield
next('world')
Resumes: y = 'world'
'world' becomes the result of yield. Runs to return, returns { value: 'done', done: true }

yield* — Delegating to Another Iterator

function* concat(...iterables) {
  for (const iterable of iterables) {
    yield* iterable; // Delegates to each iterable's iterator
  }
}

[...concat([1, 2], [3, 4], "ab")]; // [1, 2, 3, 4, "a", "b"]

Lazy Iteration — The Real Power

This is where generators go from "neat trick" to "genuinely powerful." They compute values on demand and don't create arrays in memory:

// Eager — creates all values immediately (uses memory)
function eagerRange(n) {
  const result = [];
  for (let i = 0; i < n; i++) result.push(i);
  return result;
}
eagerRange(1_000_000); // Array with 1M elements in memory

// Lazy — computes one value at a time (constant memory)
function* lazyRange(n) {
  for (let i = 0; i < n; i++) yield i;
}
// Only the current value exists in memory at any time

// Composable lazy operations
function* map(iterable, fn) {
  for (const item of iterable) yield fn(item);
}

function* filter(iterable, predicate) {
  for (const item of iterable) {
    if (predicate(item)) yield item;
  }
}

function* take(iterable, n) {
  let count = 0;
  for (const item of iterable) {
    if (count++ >= n) return;
    yield item;
  }
}

// Chain them — processes ONE element at a time through the pipeline
const result = [...take(
  filter(
    map(lazyRange(1_000_000), x => x * 2),
    x => x % 3 === 0
  ),
  5
)];
// [0, 6, 12, 18, 24] — only computed 10 values total, not 1M
Infinite sequences with generators

Since generators are lazy, they can represent infinite sequences:

function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

// Take only what you need
[...take(fibonacci(), 10)]; // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

This is safe because the infinite loop only runs when next() is called. Without take(), [...fibonacci()] would run forever.

Async Generators — Streaming Data

What if you could combine the laziness of generators with async/await? You can. Async generators let you stream asynchronous data with elegant, readable code:

async function* fetchPages(url) {
  let page = 1;
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    if (data.items.length === 0) return; // No more pages
    yield* data.items;
    page++;
  }
}

// Consume with for await...of
for await (const item of fetchPages("/api/users")) {
  processUser(item);
  // Processes each page as it arrives
  // Doesn't load all pages into memory at once
}

Production Scenario: Paginated API Client

async function* paginatedFetch(endpoint, pageSize = 100) {
  let cursor = null;
  do {
    const url = new URL(endpoint);
    url.searchParams.set("limit", pageSize);
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url);
    const { data, nextCursor } = await res.json();

    for (const item of data) yield item;
    cursor = nextCursor;
  } while (cursor);
}

// Usage — clean, memory-efficient, and stops fetching when you break
for await (const user of paginatedFetch("/api/users")) {
  if (user.role === "admin") {
    console.log("Found admin:", user.name);
    break; // Stops fetching more pages immediately
  }
}
What developers doWhat they should do
Confusing iterable (has [Symbol.iterator]) with iterator (has next())
for...of calls [Symbol.iterator]() to get the iterator, then calls next() on it
An iterable produces an iterator. An iterator produces values. Some objects are both.
Using for...in instead of for...of for iteration
for...in is for object keys. for...of is for iterable values. They solve different problems.
for...of uses the iterable protocol. for...in iterates enumerable string keys (including inherited).
Expecting generator body to run when you call the function
The function body is lazy — it only executes in response to next() calls
Calling a generator function only creates the generator object. The body runs on first next() call.
Forgetting that [...infiniteGenerator()] hangs forever
Spread and Array.from consume the entire iterator. Infinite iterators never return done: true.
Always use a take/limit mechanism with infinite generators
Quiz
What does [...'cafe\u0301'] produce?
Quiz
What value does x receive in: function* gen() { const x = yield 42; }
Quiz
Which of these is true about Symbol.iterator?
Key Rules
  1. 1An iterable has [Symbol.iterator](). An iterator has next(). A generator function creates objects that are both.
  2. 2yield pauses execution and preserves all local state. The next .next() call resumes exactly where it left off.
  3. 3Generator functions don't run on call — they return a generator object. The body starts on first next().
  4. 4Generators are lazy — they compute values on demand, enabling infinite sequences and memory-efficient pipelines.
  5. 5Async generators (async function*) combine await and yield for streaming asynchronous data with for await...of.