Skip to content
Codeloom
JavaScript

Iterators and Generators in JavaScript

A deep dive into JavaScript iterators and generators: the iterator protocol, Symbol.iterator, generator functions, yield, async generators, and practical patterns for lazy evaluation and data pipelines.

·11 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How the iterator protocol works under the hood
  • Writing custom iterables with Symbol.iterator
  • Generator functions, yield, and yield*
  • Async generators for streaming data
  • Practical patterns: lazy evaluation, pipelines, infinite sequences

Prerequisites

  • Solid JavaScript fundamentals
  • Familiarity with for...of loops
  • Basic understanding of async/await

Most JavaScript developers use iterators every day without realizing it. Every for...of loop, every spread operator, every destructuring assignment relies on the iterator protocol. Understanding how that protocol works opens the door to lazy evaluation, infinite sequences, and composable data pipelines that process millions of records without blowing up memory.

The iterator protocol

An iterator is any object with a next() method that returns { value, done }. That is the entire contract. Nothing else is required.

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

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

Each call to next() returns the next value in the sequence. When done is true, the iterator is exhausted. The protocol is stateful: calling next() advances the internal cursor and there is no way to rewind without creating a new iterator.

The iterable protocol and Symbol.iterator

An object is iterable if it has a [Symbol.iterator]() method that returns an iterator. Arrays, strings, maps, and sets all implement this protocol. The for...of loop calls Symbol.iterator behind the scenes.

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 num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}

// Spread also works because it uses the iterator protocol
const arr = [...range]; // [1, 2, 3, 4, 5]

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

The key detail: Symbol.iterator returns a new iterator each time. This means you can iterate over the same iterable multiple times. If the method returned the same iterator object, the second loop would see an exhausted iterator and produce nothing.

Built-in iterables

JavaScript ships with several built-in iterables. Each one exposes a slightly different iterator behavior.

// Arrays iterate over values
for (const val of [10, 20, 30]) {
  console.log(val); // 10, 20, 30
}

// Strings iterate over Unicode code points (not code units)
for (const char of "hi 🎉") {
  console.log(char); // "h", "i", " ", "🎉"
}

// Maps iterate over [key, value] pairs
const map = new Map([["a", 1], ["b", 2]]);
for (const [key, value] of map) {
  console.log(key, value); // "a" 1, "b" 2
}

// Sets iterate over values in insertion order
const set = new Set([3, 1, 4, 1, 5]);
for (const val of set) {
  console.log(val); // 3, 1, 4, 5
}

Plain objects are not iterable by default. Attempting for...of on a plain object throws a TypeError. You can make an object iterable by adding Symbol.iterator, or just use Object.entries() which returns an array.

Generator functions

Writing iterators by hand is tedious. Generator functions produce iterators automatically. A generator function is declared with function* and uses yield to produce values.

function* countdown(n) {
  while (n > 0) {
    yield n;
    n--;
  }
}

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

// Generators are iterable
for (const n of countdown(5)) {
  console.log(n); // 5, 4, 3, 2, 1
}

When a generator function is called, it does not execute the body. Instead, it returns a generator object that conforms to both the iterator and iterable protocols. The body runs only when next() is called, and execution pauses at each yield.

yield pauses execution

The most important thing about yield is that it suspends the generator. Local variables, the call stack position, everything is preserved. The next call to next() resumes from exactly where it left off.

function* demo() {
  console.log("before first yield");
  yield 1;
  console.log("after first yield, before second");
  yield 2;
  console.log("after second yield");
}

const g = demo();
g.next(); // logs "before first yield", returns { value: 1, done: false }
g.next(); // logs "after first yield, before second", returns { value: 2, done: false }
g.next(); // logs "after second yield", returns { value: undefined, done: true }

You can also send values into a generator. The argument to next() becomes the result of the yield expression inside the generator.

function* accumulator() {
  let total = 0;
  while (true) {
    const value = yield total;
    total += value;
  }
}

const acc = accumulator();
acc.next();      // { value: 0, done: false } — primes the generator
acc.next(10);    // { value: 10, done: false }
acc.next(20);    // { value: 30, done: false }
acc.next(5);     // { value: 35, done: false }

The first next() call runs the generator until the first yield. The value passed to that first call is always discarded, which is why we call it “priming.”

yield* for delegation

yield* delegates to another iterable. It pulls every value from the inner iterable and yields them one at a time through the outer generator.

function* concat(...iterables) {
  for (const iterable of iterables) {
    yield* iterable;
  }
}

const result = [...concat([1, 2], [3, 4], [5])];
console.log(result); // [1, 2, 3, 4, 5]

// Works with other generators too
function* innerGen() {
  yield "a";
  yield "b";
}

function* outerGen() {
  yield 1;
  yield* innerGen();
  yield 2;
}

console.log([...outerGen()]); // [1, "a", "b", 2]

yield* also forwards the return value of the inner generator. If the inner generator uses return, that value becomes the result of the yield* expression.

Infinite sequences

Generators are perfect for infinite sequences because they are lazy. Values are computed only when requested. No array is ever allocated in memory.

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

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

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

function* map(iterable, fn) {
  for (const value of iterable) {
    yield fn(value);
  }
}

// Compose: first 5 even squares starting from 1
const pipeline = take(
  map(
    filter(naturals(), (n) => n % 2 === 0),
    (n) => n * n
  ),
  5
);

console.log([...pipeline]); // [4, 16, 36, 64, 100]

This processes numbers one at a time. naturals() generates endlessly, filter skips odds, map squares, and take stops after 5 results. At no point does any intermediate array exist.

Async generators

Async generators combine generators with async/await. They are declared with async function* and can yield promises. You consume them with for await...of.

async function* fetchPages(baseUrl) {
  let page = 1;
  while (true) {
    const response = await fetch(`${baseUrl}?page=${page}`);
    const data = await response.json();

    if (data.results.length === 0) return;

    yield data.results;
    page++;
  }
}

// Consume all pages lazily
async function getAllUsers() {
  const users = [];
  for await (const page of fetchPages("/api/users")) {
    users.push(...page);
  }
  return users;
}

Async generators are ideal for paginated APIs, streaming responses, and event processing. Each yield pauses until the consumer asks for the next value, which means you only fetch the next page when the previous one has been processed.

Async iteration over streams

Node.js readable streams implement the async iterable protocol. This makes async generators a natural fit for stream processing.

import { createReadStream } from "fs";
import { createInterface } from "readline";

async function* readLines(filePath) {
  const stream = createReadStream(filePath, { encoding: "utf-8" });
  const rl = createInterface({ input: stream, crlfDelay: Infinity });

  for await (const line of rl) {
    yield line;
  }
}

async function* parseCSV(lines) {
  let headers = null;
  for await (const line of lines) {
    const fields = line.split(",");
    if (!headers) {
      headers = fields;
      continue;
    }
    const row = {};
    headers.forEach((h, i) => (row[h.trim()] = fields[i]?.trim()));
    yield row;
  }
}

async function* filterRows(rows, predicate) {
  for await (const row of rows) {
    if (predicate(row)) {
      yield row;
    }
  }
}

// Pipeline: read CSV, parse, filter — all streaming
const lines = readLines("./data.csv");
const rows = parseCSV(lines);
const active = filterRows(rows, (r) => r.status === "active");

for await (const row of active) {
  console.log(row.name, row.email);
}

This processes a CSV file of any size without loading it into memory. Each line flows through the pipeline one at a time.

Generator return and throw

Generators support return() and throw() methods for early termination and error injection.

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

const g = controlled();
g.next();           // { value: 1, done: false }
g.return("early");  // logs "cleanup", returns { value: "early", done: true }
g.next();           // { value: undefined, done: true }

return() forces the generator to execute its finally block and terminate. throw() injects an error at the point where the generator is paused.

function* resilient() {
  while (true) {
    try {
      const value = yield;
      console.log("received:", value);
    } catch (err) {
      console.log("error caught:", err.message);
    }
  }
}

const r = resilient();
r.next();                            // prime
r.next("hello");                     // "received: hello"
r.throw(new Error("something bad")); // "error caught: something bad"
r.next("still alive");               // "received: still alive"

Practical pattern: stateful tokenizer

Generators are excellent for stateful parsing because they maintain their position between calls.

function* tokenize(input) {
  const patterns = [
    { type: "NUMBER", regex: /^\d+(\.\d+)?/ },
    { type: "STRING", regex: /^"[^"]*"/ },
    { type: "IDENT", regex: /^[a-zA-Z_]\w*/ },
    { type: "OP", regex: /^[+\-*/=<>!]+/ },
    { type: "PAREN", regex: /^[()]/ },
    { type: "WS", regex: /^\s+/ },
  ];

  let pos = 0;
  while (pos < input.length) {
    let matched = false;
    for (const { type, regex } of patterns) {
      const match = input.slice(pos).match(regex);
      if (match) {
        if (type !== "WS") {
          yield { type, value: match[0], pos };
        }
        pos += match[0].length;
        matched = true;
        break;
      }
    }
    if (!matched) {
      throw new Error(`Unexpected character at position ${pos}: ${input[pos]}`);
    }
  }
}

for (const token of tokenize('x = 42 + y')) {
  console.log(token);
}
// { type: "IDENT", value: "x", pos: 0 }
// { type: "OP", value: "=", pos: 2 }
// { type: "NUMBER", value: "42", pos: 4 }
// { type: "OP", value: "+", pos: 7 }
// { type: "IDENT", value: "y", pos: 9 }

Making a class iterable

You can make any class work with for...of by implementing Symbol.iterator.

class LinkedList {
  constructor() {
    this.head = null;
  }

  append(value) {
    const node = { value, next: null };
    if (!this.head) {
      this.head = node;
      return;
    }
    let current = this.head;
    while (current.next) current = current.next;
    current.next = node;
  }

  *[Symbol.iterator]() {
    let current = this.head;
    while (current) {
      yield current.value;
      current = current.next;
    }
  }
}

const list = new LinkedList();
list.append("a");
list.append("b");
list.append("c");

for (const val of list) {
  console.log(val); // "a", "b", "c"
}

console.log([...list]); // ["a", "b", "c"]

Using a generator method (*[Symbol.iterator]()) is the cleanest approach. The generator handles state tracking automatically.

When to use generators

Generators shine in a few specific scenarios. Use them when you need lazy evaluation over large or infinite datasets, when you want composable data pipelines without intermediate arrays, when you are streaming data from I/O sources, or when you need cooperative multitasking with explicit yield points.

Avoid generators when you just need a simple array transformation (use map/filter), when the entire dataset fits comfortably in memory, or when the overhead of the generator protocol is not justified by the laziness benefit.

The iterator and generator protocols are one of JavaScript’s most underused features. They solve real problems around memory, composability, and streaming that arrays cannot. Once you recognize those problems, generators become an obvious tool to reach for.