Skip to content
Chapter 3Lesson 5

Iteration and the lazy helpers

How JavaScript loops over containers, from for...of and the object-iteration methods to generators and the ES2025 lazy iterator helpers that process a source without materializing it.

Here are two production bugs that both come from iterating the wrong way. The first allocates a million rows only to throw most of them away. The second silently picks up a property nobody put on the object. In each case the code reached for the wrong loop, or for a loop that doesn’t fit the shape of the source it runs over.

function* errorRows() {
for (let i = 0; i < 1_000_000; i++) {
yield { id: i, severity: i % 10_000 === 0 ? 'critical' : 'info' };
}
}
const top10 = [...errorRows()]
.filter((row) => row.severity === 'critical')
.slice(0, 10);

The generator can yield up to a million rows. The spread pulls every single one into an array before the .filter ever runs, so 999,990 rows are allocated only to be discarded a moment later. The chain reads as if it processed rows one at a time, but it doesn’t: the spread forces the whole source into memory first.

Neither bug is fixed with a defensive check or an extra copy. Both are fixed by choosing the right loop, and, when the source is lazy, the right set of helpers to run over it. This lesson covers what you need to make those choices: the protocol that sits underneath every for...of, the three static entry points for iterating an object (and the one banned form you’ll flag in code review), generators in just enough detail to recognize them, and the ES2025 Iterator.prototype helpers that let you compose .filter and .map over a lazy source without materializing it first.

Every container the previous four lessons covered (arrays, Set, Map, and the array Object.entries returns) shares one contract. So do strings, URLSearchParams, the NodeList from a DOM query, the value returned by FormData.entries(), and the result of calling a generator. That shared contract is the iteration protocol, and it’s what for...of, the spread operator ([...x]), Array.from, and the new Iterator.from all rely on to do their work.

The protocol has two halves. Anything with a [Symbol.iterator]() method that returns an iterator is an iterable . An iterator is anything with a .next() method that returns { value, done }, where value is the next item and done flips to true when there’s nothing left. That’s all there is to it: no class to extend, no interface to inherit, no registration step. If an object answers those two questions, the language treats it as iterable.

You almost never write an iterator by hand in 2026 application code. The one realistic place you would is a generator function, which we’ll get to in a moment. It’s worth naming the protocol now because the rest of the lesson builds directly on it. for...of is shorthand for “call [Symbol.iterator](), then call .next() in a loop until done is true.” Spread runs that same loop and collects the values into a fresh array. The iterator helpers later in the lesson are operators that wrap the iterator the protocol hands back.

Here is a short walk-through to make that concrete. The code below pulls values out of a plain array by hand, doing the same work for...of does for you.

const arr = [1, 2];
const iter = arr[Symbol.iterator]();
iter.next();
iter.next();
iter.next();

Array implements the iteration protocol: it has a [Symbol.iterator]() method that returns a fresh iterator pointed at the start of the array. Every iterable has this method. You almost never call it by hand because for...of does it for you; we’re calling it explicitly here to make the contract visible.

const arr = [1, 2];
const iter = arr[Symbol.iterator]();
iter.next();
iter.next();
iter.next();

Each .next() advances the iterator and returns { value, done }. The first call returns { value: 1, done: false }: the first element, with more to come. The second returns { value: 2, done: false }: the last element, but done is still false because the iterator only reports done on the call after the final value, not on the call that hands it out.

const arr = [1, 2];
const iter = arr[Symbol.iterator]();
iter.next();
iter.next();
iter.next();

One more .next() returns { value: undefined, done: true }. The iterator is exhausted, and value is undefined because there’s nothing left to hand back. for...of stops the moment it sees done: true and never runs the body for the undefined value. That’s the whole contract: call .next() until done, and ignore the trailing undefined.

1 / 1

The goal here isn’t to leave you writing iterators by hand. It’s to let you read what for...of, spread, and Array.from are doing under the hood, and to see where the lazy helpers later in this lesson plug in. The protocol is the joint between the containers from the previous four lessons and the loops and helpers that consume them in this one.

The lesson on array methods named the four triggers that tip the choice from a .map/.filter chain over to a for...of loop. Those triggers still apply, and it’s worth restating them here in terms of the protocol: for...of is the loop you reach for over anything iterable, which means everything in this chapter and most of the data you’ll loop over in the chapters ahead.

The mechanics are exactly what the protocol implies. for...of calls [Symbol.iterator](), then calls .next() until done, running the loop body with each value. It works naturally with break, continue, return, and throw, and, most usefully, with await inside the body.

The five things for...of lets you write that array methods don’t:

  • break, continue, or return from inside the body to short-circuit out.
  • await inside the body for sequenced async work (the trigger from the previous lesson).
  • Destructure in the binding itself, as in for (const [key, value] of map) or for (const { id, amountCents } of invoices).
  • The .entries() shape for index plus value, as in for (const [i, item] of arr.entries()).
  • Multiple statements per iteration with their own local bindings, without stuffing them into a .forEach callback.

The four triggers from the array-methods lesson come down to one sentence: reach for for...of when you need async sequencing, early termination, multi-statement bodies, or both index and value. When none of those apply, a .map/.filter chain reads cleaner.

Here is one example pulling several of those together. The invoice domain from the previous lessons is { id, amountCents, status, customerId, dueDate }, this time keyed by id in an invoicesById lookup. The loop walks until the running total crosses a threshold, then returns the id where it crossed:

const findFirstOverBudget = (
invoicesById: Record<string, Invoice>,
budgetCents: number,
): string | undefined => {
let runningTotal = 0;
for (const [id, invoice] of Object.entries(invoicesById)) {
runningTotal += invoice.amountCents;
if (runningTotal >= budgetCents) return id;
}
return undefined;
};

Two things from the list happen at once. The for (const [id, invoice] of ...) binding destructures each [key, value] pair as it arrives, so both halves are bound in one move. The return id exits the loop the moment the running total crosses the threshold, so the remaining entries are never walked. A .reduce callback can do neither: .reduce has no way to stop early, and destructuring the pair inside the callback signature is awkward for what should read as one line. The triggers, not a preference for the syntax, are what make for...of the right tool here.

Plain objects don’t implement [Symbol.iterator] themselves. Writing for (const x of someObject) throws a TypeError at runtime: “someObject is not iterable.” This trips up developers coming from Python, where for ... in dict walks the keys natively. JavaScript splits the work into two steps: you call one of three static methods to get an iterable view of the object, then iterate that.

Here are the three entry points, named once in the lesson on objects and recalled here as the consumer surface:

  • Object.entries(obj) returns [key, value] pairs. This is the default reach, since you usually want both halves.
  • Object.keys(obj) returns keys only.
  • Object.values(obj) returns values only.

All three return arrays of own properties , so the prototype chain doesn’t leak through. The senior reach for “loop over an object” is for (const [key, value] of Object.entries(obj)).

One small TypeScript wrinkle is worth recalling from the objects lesson: Object.keys(obj) and Object.entries(obj) widen the key type to string[], not keyof typeof obj. At runtime obj might carry extra keys the compile-time type doesn’t know about, so typing the keys as the declared ones would be a lie TypeScript refuses to tell. In practice you read the value, which is typed correctly, and ignore the widened key, or you assert the type at a boundary you trust.

for...in exists, but the course never writes it. Here’s why, gathered in one place so you can flag it on sight in code review.

for...in iterates string keys, including inherited enumerable ones. Two parts of that sentence cause trouble in production code. The first is that it walks the prototype chain: every enumerable property on Object.prototype, and on any prototype between yours and Object.prototype, shows up in the iteration. So if a third-party script or an older polyfill adds an enumerable property to Object.prototype, every for...in loop in your app starts logging that property too. That is the second bug from the start of the lesson. The second part is that on arrays, for...in yields the indices as strings ("0", "1", "2") rather than the values, which is almost never what the developer wanted.

The senior rule: in 2026 application code, write Object.entries (or .keys / .values) when iterating an object, and for...of when iterating an array. for...in appears once, in a code-review checklist, and a single line in biome.json configures a linter to flag it project-wide.

Here are the two forms side by side, both iterating the same invoicesById record:

for (const id in invoicesById) {
const invoice = invoicesById[id];
if (invoice === undefined) continue;
console.log(id, invoice.amountCents);
}

This walks the prototype chain, so if anything on the page has added an enumerable property to Object.prototype, the body runs for it too. Under noUncheckedIndexedAccess, invoicesById[id] returns Invoice | undefined, so the body needs a defensive narrow before it can read .amountCents. You write more code, and it still has the wrong behavior.

If you ever need to ask “is this key on the object itself, ignoring the prototype?” as a check rather than a loop, that’s what Object.hasOwn(obj, key) is for (from the lesson on objects). For the loop case, Object.entries is the answer.

Generators are the easiest way to write a custom iterable, and the only way you’ll realistically reach for in 2026 application code. The lazy-helper section coming up needs a non-array source to show off laziness, and a generator is the cleanest such source to write.

A generator function uses the function* syntax (note the asterisk) and the yield keyword inside the body. Calling it doesn’t run the body; it returns an iterator. Each yield pauses the function and hands a value out, and the next .next() call resumes execution right where it left off. The returned object is both an iterator (it has .next()) and an iterable (its [Symbol.iterator]() returns itself), so it drops straight into for...of or into any of the iterator helpers we’re about to meet.

function* invoiceEvents() {
const events = [
{ id: 'evt_1', severity: 'info' },
{ id: 'evt_2', severity: 'critical' },
{ id: 'evt_3', severity: 'info' },
{ id: 'evt_4', severity: 'critical' },
];
for (const event of events) {
console.log(`yielding ${event.id}`);
yield event;
}
}

This is plain JS rather than TS, because the generator’s shape is all we care about here, and the console.log is what makes laziness observable in the next section. Every time the generator yields, you’ll see the line print. If something downstream stops pulling values early, the console.log lines stop firing with it. That’s how the next section shows that the helper chain never materializes the whole source.

In 2026 SaaS code, the places you’ll consume a generator are anything streamed: a ReadableStream from the Fetch API (later in the platform unit), or the loop over paginated API pages (later in the async chapter). The place you’ll write one is exactly this case: you need a lazy source for a helper chain, and a generator is shorter to write than a hand-rolled [Symbol.iterator] method. That’s the whole of it. There’s no exercise here, because recognizing a generator when you see one is all you need from this section.

This is the central idea of the lesson. ES2025 added a layer of methods directly to Iterator.prototype: the same .map, .filter, .take, and .reduce you know from arrays, but operating on the iterator itself. They run lazily and never build intermediate arrays. Because they live on every iterator in the language, they work on generators, Map.values(), Set.values(), the iterator returned by Object.entries, and anything else iterable. Node 24 LTS, the runtime the course pins, ships them, and so does every modern browser.

The instinct after reading that paragraph is to use these everywhere in place of array methods, and that instinct is wrong. The point of these helpers is to handle sources that array methods handle badly. If you can already point at an in-memory array, array methods are still the right tool. Only three things tip the choice toward the helpers, and it’s worth holding them clearly in mind.

Reach for the iterator helpers when one of these holds:

  1. The source is itself lazy: a generator, a stream, or a paginated API loop. You don’t want to pull all of it at once; you want each pipeline stage to pull just what the next stage needs.
  2. The source is large enough that materializing it is wasteful. Even if you could pull it all, allocating a million-row array just to throw most of it away is the wrong shape.
  3. The pipeline short-circuits. You only need the first N matches, or the first match that satisfies a condition. Array methods walk the whole array; the helpers stop pulling the moment the terminal step has what it asked for.

On an in-memory list of 50 invoices, invoices.filter(isOverdue).map(toReminder) is the right shape: eager, readable, and terminal in one step. The iterator helpers earn their place only when materializing the source is something you want to avoid, which is what the three triggers are there to flag.

The methods split into two groups by what they do. Lazy methods return a new iterator and run nothing: they wire up the next stage of the pipeline and wait. Terminal methods pull the chain: they call .next() on the iterator they wrap, which calls .next() on the one it wraps, all the way back to the source.

  • Lazy (returns a new iterator): .map(fn), .filter(fn), .take(n), .drop(n), .flatMap(fn).
  • Terminal (pulls the chain): .toArray(), .reduce(fn, init), .forEach(fn), .some(fn), .every(fn), .find(fn).
  • Entry point: Iterator.from(iterable) wraps any iterable (array, set, map, generator, or custom) so the helper chain becomes available on it.

Nothing in the lazy half runs until the terminal step asks. .toArray() pulls everything. .find, .some, and .every pull until a match short-circuits the answer. .take(n) followed by .toArray() pulls exactly n items through the chain, plus whatever the upstream stages had to discard to find them.

Here is the visual that makes the laziness click. Each terminal call pulls one value through the chain at a time, so the source yields only what the terminal needs.

invoiceEvents() generator source .filter(isCritical) lazy .take(10) lazy .toArray() terminal pull (next) value pull (next) value pull (next) value
Each terminal call pulls one value through the chain at a time — the source yields only what the terminal needs.

Reading the diagram right-to-left: .toArray() asks .take(10) for one value. .take(10) asks .filter(isCritical) for one value. .filter asks the source for one value, checks it against isCritical, and either passes it back (if critical) or asks the source again (if not). The value then flows back along the chain, in the opposite direction from the pull. Once .take(10) has handed out its tenth value, it answers done: true on the next pull and the whole chain stops. The source generator yields exactly the items it had to produce to get ten values through the filter, and not one more.

That is the entire benefit. If the first ten critical events sit in the first fifty items of a million-row stream, the source yields fifty times. The other 999,950 are never produced at all.

Here is the first bug from the start of the lesson, fixed.

const topCritical = [...invoiceEvents()]
.filter((event) => event.severity === 'critical')
.slice(0, 10);

This materializes the entire stream first, then filters, then slices. If invoiceEvents() can yield a million rows, the spread allocates an array of a million objects before .filter reads its first byte. That’s the wrong shape for a lazy source.

The two shapes look almost identical on the page. The difference is in what runs, and when. The eager version reads the entire source up front; the lazy version reads one value at a time and stops the moment the terminal step has what it asked for. They read the same, but they can be two orders of magnitude apart in how much memory they allocate.

Here is a short drill to make the laziness concrete. The generator below console.logs on each yield, which is the observable signal we set up earlier. Read the code, then predict what prints before result resolves.

Predict what this program prints, then press Check.

function* events() {
const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
for (const item of items) {
console.log(`yielding ${item}`);
yield item;
}
}
const isMatch = (item) => item === 'c' || item === 'f' || item === 'g';
const result = Iterator.from(events()).filter(isMatch).take(2).toArray();
console.log(result);

The point of that drill is that the source generator runs only as far as the terminal step demands. Six console.log lines fire, not seven, because the source is never asked for g. The same code rewritten as [...events()].filter(isMatch).slice(0, 2) would fire all seven console.logs before .filter even started looking, allocating g for nothing.

This drill runs the other way around. It’s the same shape as the first bug from the start of the lesson, with a test that asserts both the result and the number of times the source generator yielded. The eager form passes the result check, but only the lazy form passes the count check. That failing count is the whole point: you watch “yielded 1000 times” turn into “yielded N times” once the rewrite lands.

firstNMatches is implemented eagerly — it spreads the entire source into an array, then filters, then slices. The 'returns the right matches' test passes already. The 'only pulls what it needs' test checks that the source generator's yield fired at most 425 times (not 1000). Rewrite the body using Iterator.from, .filter, .take, and .toArray so both tests pass. Keep the signature the same.

    Reveal the answer
    const firstNMatches = (source, predicate, n) => {
    return Iterator.from(source).filter(predicate).take(n).toArray();
    };

    Iterator.from(source) wraps the generator so the helper chain is available. .filter and .take are lazy: they wire up the pipeline but pull nothing yet. .toArray() is the terminal step that pulls one value at a time. The source yields only what .take(n) needs: for n = 5 and the predicate n % 100 === 0, the generator’s yield fires 401 times (0 through 400), not 1000.

    The shape of the rewrite is what matters. The arguments don’t change, the return type doesn’t change, and the test for the returned value doesn’t change. What changes is the number of times the source had to yield to get there. That’s the production win: not microseconds shaved off a benchmark, but a stream you can compose against without first asking whether your server can hold all of it in memory.

    The most common mistake after learning the iterator helpers is reaching for them by reflex on a 50-row array. The helpers earn their place against the three triggers above, and when none of those fire, you’re trading readability for nothing in return.

    Most application data fits comfortably in memory. A .filter(isOverdue).map(toReminder) over an in-memory array of fifty invoices is the right shape: eager, readable, terminal in one step, and no wrapper. Wrapping that array in Iterator.from(...) adds a method call and no observable benefit. The senior rule fits in one line: if you can already point at the array, don’t wrap it; if the source yields values over time, do.

    When the source itself is asynchronous, such as a ReadableStream from fetch, server-sent events, or a paginated API that returns a Promise<Page> per pull, the synchronous for...of doesn’t fit. The shape you want instead is for await (const value of asyncIterable): the same loop body, but it awaits each value before binding it. You’ll meet for await...of properly in the async chapter, and again when we cover streamed fetch. The lazy iterator helpers have an async cousin too, the async iterator helpers, which ship under a separate spec; the course reaches for them only where they earn their place. For now it’s enough to recognize the shape:

    for await (const chunk of response.body) {
    process(chunk);
    }

    When you see for await in code or in a future lesson, the only new piece is the await keyword between for and (. The rest is the same iteration protocol from this lesson, with a Promise between each pull and the value.