Course outline · 0% complete

0/27 lessons0%

Course overview →

Iterables: how for...of really works

lesson 7-1 · ~11 min · 17/27

The fastest shape is to start all three, then await Promise.all([...]) once.

Sequential awaits stack the wait times, since the second call does not begin until the first resolves.

Starting the promises first overlaps them, and Promise.all collects the results in input order so destructuring stays safe.

The version you would actually write is await Promise.all(ids.map(fetchOne)), which starts every request during the map and waits once.

Now for new territory. The rest of this unit is about what for...of does under the hood, and about the protocol that makes it work on arrays, strings, and objects you write yourself.

The iteration protocol

for...of works on arrays, strings, Maps, Sets, and objects you write yourself.

What lets one loop walk all of those is a shared contract with two halves.

  • An iterator is any object with a next() method returning { value, done }. Each call hands over the next item, and done: true means finished.
  • An iterable is any object that can produce an iterator, through a method stored under the special key Symbol.iterator.

About that key: a symbol is a guaranteed-unique value used as a hidden property key.

The language uses well-known symbols like Symbol.iterator so these hook methods can never collide with your normal property names. Lesson 7-3 covers symbols in their own right.

The protocol exists to avoid an explosion of special cases. The language has many consuming constructs, including for...of, spread, array destructuring, and the Map and Set constructors, and many collection types.

Instead of every construct knowing every type, they all speak { value, done }. Implement the contract once on your own class and all of those constructs work on it for free.

One consequence is worth stating early. Plain objects are not iterable, which is why for...of over { a: 1 } throws while for...in works, and why Object.entries exists.

Driving an iterator by hand

This is what for...of does for you.

const letters = ["a", "b"];
const it = letters[Symbol.iterator]();

console.log(it.next());
console.log(it.next());
console.log(it.next());

Output

{ value: 'a', done: false }
{ value: 'b', done: false }
{ value: undefined, done: true }

Get the iterator, then call next until done is true.

The third call is the one that matters for understanding the loop. It carries no value and reports completion, and for...of uses it to decide to stop rather than to run the body again.

The iterator is stateful and single-use. Calling next() a fourth time keeps returning the done result, and there is no way to rewind it.

Each call to letters[Symbol.iterator]() produces a fresh iterator, which is why two for...of loops over the same array both start at the beginning.

Square brackets are needed because the key is a symbol rather than an identifier. letters.Symbol.iterator would look for a property literally named Symbol.

iterable[Symbol.iterator]()iteratornext() → { value, done }makesfor...of asks the iterable for an iterator, then calls next() until done
The two-part contract: iterables hand out iterators, iterators hand out { value, done } pairs.

It loops twice, because strings are iterable and hand out one character per next().

Strings implement Symbol.iterator, so the loop body runs with "h" and then "i".

The same fact is why array destructuring and the Set constructor accept strings directly, as in const [first] = "hi" and new Set("hello").

String iteration walks by code point rather than by code unit, which is a genuine improvement over indexing. An emoji built from a surrogate pair comes out as one item from for...of and as two from str[0] and str[1].

That makes [..."héllo"].length a more honest character count than "héllo".length for text outside the basic Latin range.

Spread relies on the same protocol, so [..."hi"] produces ["h", "i"] without any string-specific code.

A symbol key can never collide with someone's ordinary "iterator" property.

Symbols are unique values, so the language can attach hook behavior to objects without ever clashing with user data.

Your object could have a plain .iterator property and still implement Symbol.iterator separately, and the two would be completely unrelated.

That guarantee matters because the language kept adding hooks after millions of lines of code already existed. A string key like "iterator" would have silently changed the behavior of every object that happened to use that name.

Symbol keys are also skipped by the ordinary enumeration paths. Object.keys, JSON.stringify, and for...in all ignore them, so a hook does not show up as data.

Other well-known symbols follow the same pattern, including Symbol.asyncIterator for for await...of and Symbol.toPrimitive for coercion. Recognizing the convention is more useful than memorizing the list.

Making an object iterable

One method turns a plain object into something for...of accepts.

const countdown = {
  from: 3,
  [Symbol.iterator]() {
    let current = this.from;
    return {
      next() {
        if (current >= 1) {
          return { value: current--, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  },
};

for (const n of countdown) {
  console.log(n);
}

Output

3
2
1

The position lives in a closure, since let current = this.from is captured by the returned next, and lesson 1-2 pays off here.

next() returns { value: current--, done: false } while current is at least 1, and the done result afterward.

current-- returns the value before decrementing, which is what makes the sequence start at 3 rather than 2.

Declaring current inside the Symbol.iterator method rather than on the object is the detail that makes repeat loops work. Each loop calls the method again and gets a fresh counter.

this.from is read at iterator-creation time, so changing countdown.from between loops changes the next run.

Everything that speaks the protocol now works, so [...countdown] gives [3, 2, 1] and Math.max(...countdown) gives 3. The next lesson writes the same thing in four lines with a generator.