You have been using for...of since Module 2 without ever asking how it works. It walks arrays, but also strings, Maps and Sets; it can be interrupted with break; and it works just the same with structures that have no indices. None of that is a coincidence: behind it there is a protocol, a contract any object can fulfil in order to declare itself walkable. Understanding it has two immediate rewards. The first is that you will be able to write for (const task of board) over your own class, instead of for (const task of board.tasks). The second is that it opens the door to generators, functions that pause and resume, produce values only when they are asked for and allow infinite sequences without taking up memory. In this lesson, the last of the module, you will see both protocols from the inside, make Board iterable, rewrite the id generator from 03-04, walk the subtask tree from 03-07 lazily, and read the backlog page by page with asynchronous iterators.

Contents

  1. What for...of does underneath
  2. The iterator protocol: next() and { value, done }
  3. Walking an array by hand with its iterator
  4. The iterable protocol: Symbol.iterator
  5. Making Board iterable
  6. The built-in iterables and who consumes them
  7. Generators: function* and yield
  8. Lazy execution and infinite sequences
  9. yield*: delegating to another iterable
  10. return and throw in a generator
  11. Asynchronous iterators and for await...of
  12. When to use generators and when not to
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. What for...of does underneath

When you write this:

for (const task of backlog) {
  console.log(task.title);
}

the engine does not do what you may have imagined —walking indices from 0 to length - 1. It does something more general:

  1. It asks the object for its iterator, by calling its [Symbol.iterator]() method.
  2. It calls next() on that iterator.
  3. If the result has done: false, it assigns its value to the loop variable and runs the body.
  4. It repeats step 2 until it gets done: true.
flowchart TD
    A["for (const x of collection)"] --> B["collection[Symbol.iterator]()<br/>→ returns an iterator"]
    B --> C["iterator.next()"]
    C --> D{"done?"}
    D -->|"false"| E["x = value<br/>run the body"]
    E --> C
    D -->|"true"| F["end of the loop"]

The fact that the loop knows nothing about indices is exactly what lets it walk a Set, a Map or a string with the same syntax. And it also explains why for...of does not work over an object literal: a plain object has no [Symbol.iterator], so it does not fulfil the contract.

const task = { id: 1, title: 'Redesign the multipurpose room' };
// for (const x of task) { }
// ✗ TypeError: task is not iterable

There is the explanation of that distinction from 04-01 and 04-04 between for...in (walks the keys of any object) and for...of (walks the values of an iterable). They are two completely different mechanisms, not two variants of the same one.

  1. The iterator protocol: next() and { value, done }

There are two contracts, and it is best not to mix them up.

Protocol What it requires Who fulfils it
Iterable A [Symbol.iterator]() method that returns an iterator Array, String, Map, Set, your Board
Iterator A next() method that returns { value, done } The object [Symbol.iterator]() produces

An iterator is an object with a next() method that, on every call, returns an object with two properties:

  • value: the current element.
  • done: false if there are elements left, true when it is finished.

Let us write one by hand, with no help from the language:

'use strict';

/** Manual iterator over the open tasks of an array. */
function createOpenTasksIterator(tasks) {
  let index = 0;

  return {
    next() {
      // advance to the next open one
      while (index < tasks.length && tasks[index].status === 'done') {
        index++;
      }
      if (index >= tasks.length) {
        return { value: undefined, done: true };
      }
      const task = tasks[index];
      index++;
      return { value: task, done: false };
    }
  };
}

const it = createOpenTasksIterator(backlogData);

console.log(it.next());   // { value: { id: 1, title: 'Redesign…' }, done: false }
console.log(it.next());   // { value: { id: 2, … }, done: false }

Notice two things. That index is a variable captured by a closure (03-04): the iterator remembers where it got to between calls, without exposing that variable to anybody. And the iterator is single-use: once it reaches done: true it is exhausted, and you have to create another one to walk again.

  1. Walking an array by hand with its iterator

Arrays already have the protocol implemented. You can use it directly, skipping for...of:

'use strict';

const tags = ['space', 'design', 'carpentry'];
const it = tags[Symbol.iterator]();

console.log(it.next());   // { value: 'space',     done: false }
console.log(it.next());   // { value: 'design',    done: false }
console.log(it.next());   // { value: 'carpentry', done: false }
console.log(it.next());   // { value: undefined,   done: true  }
console.log(it.next());   // { value: undefined,   done: true  }  ← exhausted forever

And this is literally for...of taken apart, written with a while:

const iterator = tags[Symbol.iterator]();
let result = iterator.next();

while (!result.done) {
  const tag = result.value;
  console.log(tag);
  result = iterator.next();
}

Nobody writes like this day to day —that is what for...of is for— but seeing it once fixes the mental model. And it explains an important detail along the way: since the loop only asks for next() when it needs the following value, a break simply stops asking. That is why for...of can be interrupted and forEach cannot.

  1. The iterable protocol: Symbol.iterator

Symbol.iterator is a well-known symbol: a unique value the language uses as a property name for this contract. It is written in brackets because it is a computed key, the syntax you learned in 04-01.

'use strict';

const idRange = {
  from: 1,
  to: 6,

  [Symbol.iterator]() {
    let current = this.from;
    const end = this.to;

    return {
      next() {
        if (current > end) return { value: undefined, done: true };
        return { value: current++, done: false };
      }
    };
  }
};

for (const id of idRange) console.log(id);         // 1 2 3 4 5 6
console.log([...idRange]);                          // [ 1, 2, 3, 4, 5, 6 ]

With a single method that object starts working with for...of, with the spread from 04-07, with the destructuring from 04-06 and with Array.from. That is the power of programming against a protocol: you implement the contract once and gain all the syntax that consumes it.

Why a symbol and not simply the name 'iterator'? To avoid collisions. An object can have an iterator property with any meaning at all without interfering with the protocol, because Symbol.iterator is a unique, unrepeatable value nobody can reproduce by accident.

  1. Making Board iterable

With that, the improvement the introduction promised. Until now you were writing:

for (const task of board.tasks) { … }            // you have to know the internal property

By adding the protocol, the board walks itself:

// js/model/board.js
export class Board {
  #tasks = [];

  // …everything from 05-04…

  /** Makes the board iterable: for (const task of board). */
  [Symbol.iterator]() {
    return this.#tasks[Symbol.iterator]();        // we delegate to the internal array
  }
}

A single line, because the array already knows how to iterate itself. And all of this starts working:

const board = new Board('Taller Nómada', createBacklog());

for (const task of board) {
  console.log(task.describe(TODAY));
}

const all = [...board];                              // spread
const [first, second] = board;                       // destructuring
const titles = Array.from(board, (t) => t.title);    // Array.from with a transformation

console.log(all.length);        // 6
console.log(first.title);       // 'Redesign the multipurpose room'
console.log(titles.at(-1));     // 'Carpentry workshop quote'

Notice what this means for the encapsulation from 05-03: the consumer walks the tasks without our exposing the internal array. In fact, #tasks is still private, and the tasks getter that returned a copy is not even needed for walking any more.

You can also offer several ways of walking as methods that return iterables:

export class Board {
  // …

  /** Default walk: every task, in insertion order. */
  [Symbol.iterator]() {
    return this.#tasks[Symbol.iterator]();
  }

  /** Open ones only. Returns an iterable, not an array. */
  openIterable() {
    const tasks = this.#tasks;
    return {
      [Symbol.iterator]() {
        let i = 0;
        return {
          next() {
            while (i < tasks.length && !tasks[i].isOpen) i++;
            if (i >= tasks.length) return { done: true, value: undefined };
            return { done: false, value: tasks[i++] };
          }
        };
      }
    };
  }
}

for (const task of board.openIterable()) console.log(task.id);   // 1 2 3 5 6

That openIterable works, but look at all the ceremony: an object with [Symbol.iterator] that returns another object with next that manages an index by hand. It is awkward to write and easy to get wrong. In section 7 you will rewrite it in three lines with a generator.

  1. The built-in iterables and who consumes them

These are the iterables the language or the browser provides:

Iterable What it produces when walked
Array Its elements, in order
String Its characters, respecting emoji and composed characters
Map [key, value] pairs
Set Its values, with no duplicates
arguments A function's arguments (an array-like object, not an array)
NodeList (Module 6) The DOM nodes returned by querySelectorAll
TypedArray Its numbers
The result of entries(), keys(), values() Its elements

A detail that surprises people with strings:

const text = 'café';
console.log([...text]);           // [ 'c', 'a', 'f', 'é' ]
console.log('👩‍🎨'.length);          // 5  ← code units
console.log([...'👩‍🎨'].length);     // 3  ← code points

The String iterator walks code points, not 16-bit units, so it handles non-Latin characters better than numeric indexing does. It is a practical reason to prefer for...of over a classic for when walking text.

And these are the operations that consume an iterable —all of them work with Board now that we gave it the protocol:

Operation Example
for...of for (const t of board) …
Spread into an array [...board]
Spread into arguments Math.max(...hours)
Array destructuring const [first] = board;
Array.from Array.from(board, (t) => t.title)
Set and Map constructors new Set(board)
Promise.all and company Promise.all(promises)
yield* section 9

An important warning: Object.keys, for...in, JSON.stringify and map/filter/reduce do not use this protocol. Array methods are methods of Array.prototype (05-01) and exist only on arrays. That is why, to apply filter to an arbitrary iterable, you first have to materialize it:

const open = [...board].filter((t) => t.isOpen);       // ✓
// board.filter(...)  → only if the class defines that method

  1. Generators: function* and yield

Writing iterators by hand is tedious. Generators are functions that build them automatically.

They are declared with function* and produce values with yield:

'use strict';

function* countTo(n) {
  for (let i = 1; i <= n; i++) {
    yield i;               // "hand over this value and pause here"
  }
}

const gen = countTo(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 countTo(3)) console.log(n);   // 1 2 3

Three things happen here that do not happen with an ordinary function:

  1. Calling the generator does not run its body. countTo(3) returns a generator object without executing a single line of the for.
  2. Each next() runs up to the next yield and pauses there, keeping all the local variables.
  3. The generator object is both an iterator and an iterable: it has next() and also a [Symbol.iterator]() returning itself. That is why it works directly in a for...of.

That pause is the unique feature: an ordinary function, once it starts, runs to the return. A generator can stop halfway and carry on later, exactly where it left off.

Now rewrite the openIterable from section 5:

export class Board {
  // …

  /** Only the open tasks, lazily. */
  *open() {
    for (const task of this.#tasks) {
      if (task.isOpen) yield task;
    }
  }

  /** Tasks belonging to an assignee. */
  *by(assignee) {
    for (const task of this.#tasks) {
      if (task.assignee === assignee) yield task;
    }
  }

  /** Tasks overdue on a given date (R10). */
  *overdue(today) {
    for (const task of this.#tasks) {
      if (task.isOverdue(today)) yield task;
    }
  }
}

for (const t of board.open()) console.log(t.id);          // 1 2 3 5 6
for (const t of board.by('Iván')) console.log(t.title);   // Iván's 3 tasks
console.log([...board.overdue(TODAY)].length);            // 1

From twenty lines of ceremony to three. Notice the *open() syntax: it is a generator method inside a class, and it also exists in object literals (*method() { … }) and as *[Symbol.iterator]() itself.

In fact, the board's iterable can be written like this:

*[Symbol.iterator]() {
  yield* this.#tasks;          // the yield* from section 9
}

  1. Lazy execution and infinite sequences

The fact that a generator only works when it is asked to is called lazy evaluation, and it has two enormous consequences.

Consequence 1: what is not used is not computed.

'use strict';

function* tasksWithTrace(tasks) {
  for (const t of tasks) {
    console.log(`  · evaluating ${t.id}`);
    yield t;
  }
}

// Looking for the first one over 10 h
for (const t of tasksWithTrace(backlogData)) {
  if (t.estimatedHours > 10) {
    console.log(`Found: ${t.title}`);
    break;                                   // ← the generator does not carry on
  }
}
//   · evaluating 1
// Found: Redesign the multipurpose room

Only one task was evaluated. Compare it with backlogData.filter(t => t.estimatedHours > 10)[0], which walks all six and builds an intermediate array. With six elements it makes no difference; with two hundred thousand, the difference is abysmal.

Consequence 2: infinite sequences can be represented. An infinite array is impossible; an infinite generator is trivial, because the values do not exist until they are asked for.

Let us go back to the id generator from 03-04, that closure returning a function:

// ── The 03-04 version, with a closure ─────────────────────────────
function createIdGenerator(initial = 0) {
  let lastId = initial;
  return function nextId() {
    lastId = lastId + 1;
    return lastId;
  };
}

// ── The generator version ─────────────────────────────────────────
function* idGenerator(initial = 0) {
  let id = initial;
  while (true) {              // ✓ an infinite loop that is perfectly safe
    id += 1;
    yield id;
  }
}

const ids = idGenerator(6);         // the canonical backlog goes up to 6

console.log(ids.next().value);      // 7
console.log(ids.next().value);      // 8
console.log(ids.next().value);      // 9

That while (true) hangs nothing because the generator is stopped at the yield most of the time: it only advances one turn per next(). Compare the two approaches:

Closure (03-04) Generator
How you ask for the next one nextId() ids.next().value
Internal state A captured variable Paused local variables
Does it work with for...of? No Yes
Does it combine with take, yield*…? No Yes
Readability of the sequence Inferred from the body Explicit: it reads like a loop

Watch out for one obvious trap: never spread an infinite generator.

// const all = [...idGenerator()];       // ⚠ hangs the program forever

To consume part of it you need a function that takes the first N —and which, like any other combination of iterables, is itself a generator:

/** Takes the first n elements of any iterable. */
function* take(iterable, n) {
  let counted = 0;
  for (const value of iterable) {
    if (counted >= n) return;
    yield value;
    counted++;
  }
}

console.log([...take(idGenerator(6), 5)]);   // [ 7, 8, 9, 10, 11 ]

And while we are at it, lazy map and filter, which work over infinite sequences unlike their array namesakes:

function* lazyMap(iterable, transform) {
  for (const v of iterable) yield transform(v);
}

function* lazyFilter(iterable, predicate) {
  for (const v of iterable) if (predicate(v)) yield v;
}

const evenIds = lazyFilter(idGenerator(0), (n) => n % 2 === 0);
const labelled = lazyMap(evenIds, (n) => `T-${String(n).padStart(3, '0')}`);

console.log([...take(labelled, 4)]);   // [ 'T-002', 'T-004', 'T-006', 'T-008' ]

That chain processes the values one at a time through the three functions, without building a single intermediate array. It is the composition from 03-06 applied to data streams.

  1. yield*: delegating to another iterable

yield* (with an asterisk) hands over all the values of another iterable, one by one, as if they were written right there.

function* firstPair() { yield 1; yield 2; }
function* secondPair() { yield 3; yield 4; }

function* everything() {
  yield* firstPair();
  yield* secondPair();
  yield 5;
}

console.log([...everything()]);   // [ 1, 2, 3, 4, 5 ]

Without the asterisk, yield firstPair() would hand over the generator object as a single value, not its elements.

Its star application is recursion over nested structures, and here we return to the subtask tree from 03-07: "Redesign the multipurpose room" with its 12 h spread out and 7 h open.

'use strict';

const redesignTask = {
  id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván',
  status: 'in-progress', estimatedHours: 0,
  subtasks: [
    { id: 11, title: 'Measure and draw up the floor plan', assignee: 'Iván',
      status: 'done', estimatedHours: 3, subtasks: [] },
    { id: 12, title: 'Choose the furniture', assignee: 'Marta',
      status: 'in-progress', estimatedHours: 0,
      subtasks: [
        { id: 121, title: 'Request quotes', assignee: 'Marta',
          status: 'done', estimatedHours: 2, subtasks: [] },
        { id: 122, title: 'Visit two suppliers', assignee: 'Marta',
          status: 'pending', estimatedHours: 2, subtasks: [] }
      ] },
    { id: 13, title: 'Paint and assemble', assignee: 'Iván',
      status: 'pending', estimatedHours: 5, subtasks: [] }
  ]
};

/** Walks a task tree depth-first, lazily. */
function* walkTree(task, depth = 0) {
  yield { task, depth };
  for (const sub of task.subtasks ?? []) {
    yield* walkTree(sub, depth + 1);                 // ← recursive delegation
  }
}

for (const { task, depth } of walkTree(redesignTask)) {
  console.log(`${'  '.repeat(depth)}[${task.id}] ${task.title} · ${task.estimatedHours} h`);
}
// [1] Redesign the multipurpose room · 0 h
//   [11] Measure and draw up the floor plan · 3 h
//   [12] Choose the furniture · 0 h
//     [121] Request quotes · 2 h
//     [122] Visit two suppliers · 2 h
//   [13] Paint and assemble · 5 h

Compare it with flattenTasks from 03-07, which built a complete array with concat. Here no array is created: the nodes are handed over as they are visited. And on top of that lazy walk everything else comes for free:

const nodes = [...walkTree(redesignTask)].map((n) => n.task);

console.log(nodes.reduce((s, t) => s + t.estimatedHours, 0));                        // 12
console.log(nodes.filter((t) => t.status !== 'done').reduce((s, t) => s + t.estimatedHours, 0));   // 7

// Searching without walking the whole tree: it stops as soon as it finds it
function findById(root, id) {
  for (const { task } of walkTree(root)) {
    if (task.id === id) return task;
  }
  return null;
}
console.log(findById(redesignTask, 121).title);   // 'Request quotes'

The canonical tree's 12 h in total and 7 h open, with a walk that can also be interrupted halfway. That is the concrete advantage of laziness over recursion that returns arrays.

  1. return and throw in a generator

A generator object has two more methods besides next().

return(value) ends the generator early:

function* count() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log('generator cleanup');           // runs all the same
  }
}

const g = count();
console.log(g.next());        // { value: 1, done: false }
console.log(g.return(99));    // generator cleanup
                              // { value: 99, done: true }
console.log(g.next());        // { value: undefined, done: true }  ← exhausted

That finally is the main reason this method exists: it lets you release resources —close a file, a connection— even if the consumer walks away halfway. And the important part: for...of calls return() automatically when you leave with break, return or an exception. In other words, the cleanup is guaranteed:

for (const n of count()) {
  if (n === 2) break;         // triggers the call to return() → the cleanup is printed
}

throw(error) injects an exception at the point where the generator is paused:

function* process() {
  try {
    yield 'ready';
    yield 'I never get here';
  } catch (error) {
    console.log('the generator caught:', error.message);
    yield 'recovered';
  }
}

const p = process();
console.log(p.next().value);                                // 'ready'
console.log(p.throw(new Error('external failure')).value);  // the generator caught: external failure
                                                            // 'recovered'

It is rarely used in application code, but it is worth knowing: together with the possibility of passing a value to next(v) —which arrives as the result of the yield where the generator was paused— it forms a two-way communication channel. The first implementations of async/await were built on that mechanism, before it became language syntax.

  1. Asynchronous iterators and for await...of

One last case remains: walking something whose elements arrive over time, such as the pages of results from a server. That is what the asynchronous protocols are for, the promise-based version of the previous two.

Synchronous Asynchronous
The iterable's method [Symbol.iterator]() [Symbol.asyncIterator]()
What next() returns { value, done } A promise of { value, done }
How the generator is declared function* async function*
How it is consumed for...of for await...of

Applied to reading the Taller Nómada backlog through simulated pages:

// js/data/paginated.js
import { Task } from '../model/task.js';
import { backlogData } from './backlog.js';

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Simulates a paginated API. In Module 7 this will be fetch with ?page=N (07-02).
 * @returns {Promise<{tasks: Object[], hasMore: boolean}>}
 */
async function readPage(number, perPage = 2) {
  await wait(300);                                      // simulated latency
  const start = (number - 1) * perPage;
  const chunk = backlogData.slice(start, start + perPage);
  return { tasks: chunk, hasMore: start + perPage < backlogData.length };
}

/** Asynchronous generator: hands over the tasks page by page, on demand. */
export async function* readPaginatedBacklog(perPage = 2) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    console.log(`  ⇩ requesting page ${page}…`);
    const response = await readPage(page, perPage);
    for (const data of response.tasks) {
      yield new Task(data);                             // hands over task by task
    }
    hasMore = response.hasMore;
    page += 1;
  }
}

And its consumption, which hides the pagination completely:

import { readPaginatedBacklog } from './data/paginated.js';

let hours = 0;
for await (const task of readPaginatedBacklog()) {
  console.log(task.describe(TODAY));
  hours += task.estimatedHours;
}
console.log(`Total: ${hours} h`);

//   ⇩ requesting page 1…
// ▸ [1] Redesign the multipurpose room · Iván · 12 h
// ○ [2] Signage for the screen-printing workshop · Marta · 6 h
//   ⇩ requesting page 2…
// ○ [3] Update the bookings website · Lucía · 14 h
// ✓ [4] Screen-printing ink inventory · Marta · 3 h
//   ⇩ requesting page 3…
// ▸ [5] Bookbinding guide for residents · Iván · 8 h
// ○ [6] Carpentry workshop quote · Iván · 5 h ⚠ OVERDUE
// Total: 48 h

The canonical 48 h, obtained in three requests the consumer of the loop never sees. Three merits of this design:

  1. The pagination is encapsulated. The consumer writes an ordinary loop; the generator takes care of requesting the next page when it is needed.
  2. It is genuinely lazy. If the consumer does a break on the third task, pages 2 and 3 are never requested. With a function that returned everything as an array, they would have been requested anyway.
  3. Memory is constant. There is only one page in memory at a time, even if the backlog had a million tasks.
// Proof of the laziness: only the first page is requested
for await (const task of readPaginatedBacklog()) {
  if (task.estimatedHours > 10) { console.log('Found:', task.title); break; }
}
//   ⇩ requesting page 1…
// Found: Redesign the multipurpose room

A note from 05-06: for await...of processes the elements sequentially, waiting for each one. If what you want is to fire everything off in parallel, it is still Promise.all. Here the sequential behavior is what we want: you cannot ask for page 2 without knowing whether it exists.

  1. When to use generators and when not to

Generators are elegant, and that is why they get overused. The honest rule:

Use a generator when… Use an array or an ordinary function when…
The sequence is infinite or of unknown length You know how many elements there are and they fit in memory
Producing each element is expensive and you may not need them all Producing them is cheap
You want to be able to interrupt the walk halfway You are always going to walk the whole thing
You are walking a recursive structure (trees) and want laziness An ordinary map/filter solves the case
The data arrives in chunks (pages, streams) You have it all at once
You want to chain transformations without intermediate arrays The readability of filter().map() is preferable

And the real drawbacks, which have to be weighed:

  • There is no length. You cannot know how many elements it will produce without consuming it entirely.
  • Single use. Once a generator is consumed, that is it; you have to create another one. An array can be walked as many times as you like.
  • No map, filter or reduce. You have to write them (as in section 8) or materialize with [...gen], which cancels the laziness.
  • Debugging is harder. Putting a breakpoint inside a generator that pauses and resumes is disconcerting at first.
  • With few elements, it does not pay off. For the six-task backlog, filter is clearer and there is no gain at all. Laziness starts paying off with thousands of elements, with expensive production, or with data arriving from outside.

In Nómada Tasks we use them where they genuinely add something: [Symbol.iterator] on Board for the convenience of the API, the lazy walk of the subtask tree, and the asynchronous generator for the paginated backlog.

Common Mistakes and Tips

  • Forgetting the asterisk. function generator() with a yield inside is a SyntaxError. The asterisk can go against function or against the name: both are valid.
  • Calling the generator and expecting it to run. countTo(3) runs nothing; you have to consume it with next() or with for...of.
  • Confusing yield with yield*. Without the asterisk you hand over the whole iterable as one value; with it you hand over its elements.
  • Spreading an infinite generator. [...idGenerator()] hangs the program. Use a take(n) function.
  • Reusing an exhausted generator. It returns { done: true } forever and the loop does not go round once. If you need to walk twice, create two generators or materialize into an array.
  • Returning the same iterator from [Symbol.iterator](). If you store the iterator in a property and always return it, the second for...of will walk nothing. [Symbol.iterator]() must return a new iterator on every call.
  • Expecting for...of to work over a plain object. It is not iterable. Use Object.entries(obj), which is.
  • Using for await...of outside an async function (or the top level of a module). It is the same rule as for await.
  • Believing that for await...of parallelizes. It is sequential by definition. For parallel, Promise.all (05-06).
  • Tip: when you are unsure whether something is iterable, ask it: typeof x?.[Symbol.iterator] === 'function'. It is more reliable than assuming.

Exercises

Exercise 1 — An iterable by hand. Write a Week class that takes an ISO start date and is iterable, producing the seven days of that week in 'yyyy-mm-dd' format. Implement it without generators, with [Symbol.iterator]() returning an object with next(). Check it with for...of, with the spread and with destructuring, and verify that it can be walked twice in a row.

Exercise 2 — Composed generators. Write three generators —take(iterable, n), skip(iterable, n) and inBatches(iterable, size)— and use them to produce, from an infinite idGenerator(6), the ids from 11 to 20 grouped in batches of 3. Then apply them to the backlog to get the open tasks in batches of 2.

Exercise 3 — Asynchronous walk with an early exit. Extend readPaginatedBacklog so it accepts an options object { perPage, filter } and only hands over the tasks that pass the filter. Then write firstMatching(asyncIterable, predicate) that returns the first match while stopping the walk, and show with the console trace that only the necessary page was requested.

Solutions

Exercise 1

'use strict';

class Week {
  #start;

  constructor(isoDate) {
    this.#start = isoDate;
  }

  get start() { return this.#start; }

  [Symbol.iterator]() {
    const base = new Date(this.#start);
    let day = 0;                                  // ← NEW state on every call

    return {
      next() {
        if (day >= 7) return { value: undefined, done: true };
        const date = new Date(base);
        date.setDate(base.getDate() + day);
        day += 1;
        return { value: date.toISOString().slice(0, 10), done: false };
      }
    };
  }
}

const week = new Week('2026-09-14');              // the week of the canonical TODAY

for (const day of week) console.log(day);
// 2026-09-14 … 2026-09-20

console.log([...week].length);                    // 7
const [monday, tuesday] = week;
console.log(monday, tuesday);                     // 2026-09-14 2026-09-15

// It can be walked twice without any problem:
console.log([...week][6], [...week][0]);          // 2026-09-20 2026-09-14

The critical point of the exercise is in the two lines declaring base and day inside [Symbol.iterator](). If you had put them as class fields, the first walk would leave day at 7 and the second for...of would not go round even once: it is the "returning the same iterator" mistake from the common mistakes. Every call to the method must produce a fresh iterator, with its own state captured by a closure.

By way of comparison, the generator version fits in four lines and does not have that risk:

*[Symbol.iterator]() {
  const base = new Date(this.#start);
  for (let d = 0; d < 7; d++) {
    const date = new Date(base);
    date.setDate(base.getDate() + d);
    yield date.toISOString().slice(0, 10);
  }
}

Exercise 2

'use strict';

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

function* skip(iterable, n) {
  let skipped = 0;
  for (const v of iterable) {
    if (skipped < n) { skipped++; continue; }
    yield v;
  }
}

function* inBatches(iterable, size) {
  let batch = [];
  for (const v of iterable) {
    batch.push(v);
    if (batch.length === size) {
      yield batch;
      batch = [];                    // a new array: do not reuse the one handed over
    }
  }
  if (batch.length > 0) yield batch; // the last incomplete batch
}

// Ids from 11 to 20, in batches of 3
const ids = idGenerator(6);                          // produces 7, 8, 9, …
const fromElevenToTwenty = take(skip(ids, 4), 10);   // skips 7-10, takes 11-20

console.log([...inBatches(fromElevenToTwenty, 3)]);
// [ [ 11, 12, 13 ], [ 14, 15, 16 ], [ 17, 18, 19 ], [ 20 ] ]

// Open tasks from the backlog, in batches of 2
const board = new Board('Taller Nómada', createBacklog());
for (const batch of inBatches(board.open(), 2)) {
  console.log(batch.map((t) => t.id).join(', '));
}
// 1, 2
// 3, 5
// 6

Four observations. The composition take(skip(ids, 4), 10) works because every generator is iterable, so one can consume another: it is exactly the compose/pipe idea from 03-06, now over streams. Nothing is materialized until the final [...], and the infinite generator never causes trouble because take stops asking it for values. The batch = [] after every yield is essential: if you reused the same array, every batch handed over would be the same object and they would all end up holding the last one's contents —the same shared-reference problem from 04-08. And the last if outside the loop hands over the incomplete batch, which is almost always what you want.

Exercise 3

'use strict';

/**
 * Reads the paginated backlog, handing over only the tasks that pass the filter.
 * @param {Object}   [options]
 * @param {number}   [options.perPage=2]
 * @param {Function} [options.filter]  (task) => boolean
 */
export async function* readPaginatedBacklog({ perPage = 2, filter = () => true } = {}) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    console.log(`  ⇩ requesting page ${page}…`);
    const response = await readPage(page, perPage);
    for (const data of response.tasks) {
      const task = new Task(data);
      if (filter(task)) yield task;
    }
    hasMore = response.hasMore;
    page += 1;
  }
}

/** First match of an asynchronous iterable; stops the walk. */
async function firstMatching(asyncIterable, predicate) {
  for await (const item of asyncIterable) {
    if (predicate(item)) return item;
  }
  return null;
}

// Case 1 · the match is on the first page
const firstMatch = await firstMatching(
  readPaginatedBacklog({ filter: (t) => t.isOpen }),
  (t) => t.estimatedHours > 10
);
console.log('→', firstMatch.title);
//   ⇩ requesting page 1…
// → Redesign the multipurpose room          ← a single request

// Case 2 · the match is on the last page
const overdueTask = await firstMatching(
  readPaginatedBacklog({ filter: (t) => t.isOpen }),
  (t) => t.isOverdue(TODAY)
);
console.log('→', overdueTask.title);
//   ⇩ requesting page 1…
//   ⇩ requesting page 2…
//   ⇩ requesting page 3…
// → Carpentry workshop quote                ← three requests, exactly the ones needed

// Case 3 · no matches: the iterable is exhausted
console.log(await firstMatching(readPaginatedBacklog(), (t) => t.estimatedHours > 100));
// null (after the three pages)

The trace is the demonstration of the exercise: in the first case one page is requested, in the second three, and at no point are extra pages requested. That is achieved by the return inside the for await...of, which makes the loop call the asynchronous iterator's return() —the same mechanism as in section 10— so the generator stops cleanly at its yield, never reaching the next iteration of the while.

Compare this with the naive alternative: a function that downloads every page, builds an array of the 6 tasks and then does a find. With six tasks it is identical; with a backlog of ten thousand spread over five thousand pages, the difference between one request and five thousand is the difference between a usable application and a useless one. That is exactly the pattern you will apply in Module 7 when paginating real data with fetch.

Conclusion

You have reached the mechanism that explains a syntax you had been using since Module 2. for...of does not walk indices: it asks for an iterator with [Symbol.iterator]() and queries it with next() until it gets { done: true }. There are two distinct contracts —iterable, the one that knows how to produce an iterator; iterator, the one that knows how to hand over values one at a time— and implementing the first is enough to gain, in one go, for...of, the spread, destructuring, Array.from and the Set and Map constructors. You have checked it by writing an iterator by hand, taking for...of apart into a while, and adding one line to Board so you can write for (const task of board) without exposing the private array. You also know why for...of can be interrupted with break and forEach cannot, why a plain object is not iterable, and why map, filter and Object.keys play in a different league: they are array methods, not consumers of the protocol.

On that basis you have discovered generators: function* functions that pause at every yield and resume at every next(), keeping their local variables. Calling them runs nothing; the generator object they return is both iterator and iterable. That laziness has two consequences you have exploited fully: what is not asked for is not computed —the search that evaluates a single task and stops— and sequences can be infinite, like the idGenerator with its perfectly safe while (true) that rewrites the 03-04 closure and gains compatibility with for...of and with composition. With take, skip, lazyMap, lazyFilter and inBatches you have built data pipelines with not one intermediate array, and with yield* you have walked the subtask tree from 03-07 lazily and interruptibly, recovering its 12 h in total and its 7 h open without building the flattened list. You know return() and throw() on a generator, and the valuable detail that for...of calls return() automatically when you leave with break, which guarantees that any cleanup finally runs. And you have closed the circle with the asynchronous protocols: async function*, Symbol.asyncIterator and for await...of applied to a paginated backlog that hides the pagination from the consumer, requests only the pages it needs and keeps memory constant. Finally, you know when not to use them: no length, single use, no array methods and no advantage at all when the data is small and cheap.

That closes Module 5, and it is worth looking back. You came in with six object literals repeating their methods one by one and you leave with a complete model: the prototypes that explain how JavaScript really shares behavior; the classes that write it readably, with extends, super and polymorphism; the encapsulation with #status, getters, setters and a carefully designed public API that makes it impossible to leave a task in an illegal state; the modules that split the project into model/, data/ and util/ with explicit, cycle-free dependencies; and the whole leap into asynchrony —callbacks, promises, async/await, the event loop and microtasks, asynchronous iterators—, with loadBoard() able to fetch the data with latency, a timeout, validation and a local fallback. Taller Nómada's canonical numbers are still the same as in Module 1 —48 h in total, 45 open, Iván on 25, Lucía on 14, Marta on 6, the carpentry quote overdue, weighted effort 124— but the machinery producing them is no longer a script: it is an application.

And here comes the change of scene. Everything you have built over five modules lives in the console. Marta, Iván and Lucía are not going to open the developer tools to find out what they have to do today: they need to see the board on the screen, mark a task as done with a click, filter by assignee and add a new one from a form. The model is built, protected, modularized and tested from the console; the time has come for it to be seen. That means learning to talk to the page: selecting elements, creating them, modifying them and reacting to what the user does —and, with what you now know about the event loop, understanding exactly why a slow handler freezes the interface. It is Module 6: The Document Object Model, which begins with Introduction to the DOM.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved