Lesson 1 of 9
Iterators & the Iteration Protocol
"The three chief virtues of a programmer are: Laziness, Impatience and Hubris." — Larry Wall
Parts I–III took you through the language itself, the engine that runs it, and the asynchronous machinery that keeps it responsive. Part IV is about something you do in almost every function you write: iterate over data and transform it. And it opens with the quiet contract that makes half of modern JavaScript's syntax work. When you write for (const x of thing), or [...thing], or const [a, b] = thing, you are not using magic reserved for arrays — you are speaking a protocol that any object can implement. Learn the protocol once and a dozen features stop being special cases and become the same idea. Interviewers know this, which is why "make this object work with for...of" is such a revealing question.
What is it, exactly, that lets for...of, spread, and destructuring walk through an array, a string, a Map, and a Set with identical syntax — and how do you make your own object join the club?
Core Concepts
There are two protocols, and they interlock. Keep them separate in your head and everything else falls into place.
The iterable protocol: an object is iterable if it has a method under the special key Symbol.iterator. Calling that method returns an iterator. Symbol.iterator is a well-known symbol — a unique, unforgeable key — so it never clashes with your own property names.
The iterator protocol: an iterator is any object with a next() method. Each call to next() returns a result object { value, done }. While there's more to produce, done is false and value holds the next item. When the sequence is exhausted, next() returns { value: undefined, done: true }, and by convention stays that way forever after.
That's the whole machine. An iterable knows how to produce an iterator; the iterator produces values one at a time and announces when it's finished.
Driving an iterator by hand
Every consumer you'll meet is really just calling next() in a loop. Do it manually once and the abstraction dissolves. (We print the fields directly so the output is exact regardless of how a console formats objects — but each next() genuinely returns an object shaped { value, done }.)
Notice the array itself is not the iterator — letters[Symbol.iterator]() hands you a separate cursor. The array is the iterable; the cursor is the iterator.
What the built-ins give you for free
Many built-in types ship a [Symbol.iterator] already. That single fact is why they all cooperate with the same syntax. Plain objects ({}) are the notable exception — they are not iterable.
| Iterable | What each step yields |
|---|---|
Array / TypedArray |
each element, in order |
String |
each code point — so [...'a👋'] is ['a', '👋'], not three "chars" |
Map |
a [key, value] pair per entry |
Set |
each value, in insertion order |
arguments |
each argument passed to the function |
NodeList (DOM) |
each node — which is why for...of works on querySelectorAll results |
What actually consumes iterables
None of these features special-case arrays. Under the hood, each one calls [Symbol.iterator]() and then drains the iterator with next():
for...ofloops- spread into an array or call:
[...it],fn(...it) - array/
Set/Mapdestructuring:const [first, second] = it Array.from(it),new Set(it),new Map(it)Promise.all,Promise.race,Promise.allSettled,Promise.anyyield*delegation inside a generator
So when you make your own object iterable, all of that lights up at once — for free.
A vending machine. The machine is the iterable: you don't take snacks from it directly, you press a button that hands you a fresh dispenser cursor. That cursor is the iterator: each press of next() drops one item and tells you "here's your snack, and no, I'm not empty yet" — until the coil is bare and it says done. Crucially, one dispenser is single-use: once emptied it stays empty. Want another full run? Ask the machine for a new one.
Writing a custom iterable by hand
Here's the pay-off. Let's make a range(start, end) object that produces the numbers from start up to (but not including) end. We give it a [Symbol.iterator]() method that returns a fresh iterator each time — an object with a next():
Because [Symbol.iterator]() returns a new iterator (with its own current) on every call, range(1, 5) is reusable — you can loop it, spread it, and destructure it independently. That is exactly how the built-ins behave.
None of this existed before ES2015 (ES6). Older code walked arrays with index-counted for loops or forEach, and the tempting for...in iterates *property keys* — which quietly breaks on arrays because it picks up inherited and added properties. ES6 introduced Symbol.iterator, for...of, spread, and generators as one coordinated design: a single protocol so that any collection, present or future, could be traversed the same way. It's one of the cleanest extension points the language ever shipped.
Lazy, one value at a time — and infinite sequences
An iterator computes each value on demand, only when next() is called. Nothing forces it to hold the whole sequence in memory, and nothing forces it to be finite. Here is an infinite iterator of the natural numbers — legal precisely because values are produced lazily:
naturals() would loop forever, yet it costs almost nothing: at any instant only one number exists. Laziness is what lets you model unbounded streams — lines from a file, pages from an API, ticks of a clock — without ever "materialising" them.
Habits that separate people who *use* iteration from people who *understand* it:
• They reach for for...of, not for...in, on collections. for...in is for object keys; for...of is for iterable values.
• They spread to copy or convert: [...set] to array-ify a Set, [...map] to get [k, v] pairs, [...str] to split a string by code point safely.
• They treat a raw iterator as single-use and pass the *iterable* around when something must be traversed more than once.
• They lean on laziness to avoid loading huge or infinite sequences into memory all at once.
The one-shot gotcha
An iterator is a cursor, and a cursor only moves forward. Once fully consumed it is exhausted — draining it again yields nothing. This bites people who spread the same live iterator twice:
The array nums is still full — it's the iterator it that got used up. This is also why an object that returns this from [Symbol.iterator]() (like naturals above) can only be looped once. Reusable iterables dodge this by returning a brand-new iterator each time, which is exactly what range did.
When the Academy renders a course, its modules live in a Map of moduleId → lessons, and the view layer does for (const [id, lessons] of moduleMap) — no .entries(), no index bookkeeping, because a Map is iterable and yields [key, value] pairs directly. And when an admin exports thousands of quiz attempts, the code treats the database result as an iterator and processes rows one at a time rather than pulling the entire table into memory — the same laziness that makes an infinite sequence cheap makes a huge one safe.
Common Mistakes
- Confusing
for...inwithfor...of.for...inwalks enumerable keys (and inherited ones);for...ofwalks values of an iterable. Usingfor...inon an array is a classic, subtle bug. - Expecting plain objects to be iterable.
for (const x of {a: 1})throws — objects have no[Symbol.iterator]. IterateObject.keys/values/entries(obj)instead (those return arrays, which are iterable). - Reusing an exhausted iterator. After a full
for...ofor spread, a raw iterator is spent. Get a fresh one from the iterable for a second pass. - Returning
thisfrom[Symbol.iterator]()when you need reusability. That makes the object its own iterator — fine for one-shot streams, but it can only be traversed once. - Forgetting to signal
done. An iterator whosenext()never returns{ done: true }will hang anyfor...of, spread, orPromise.allthat consumes it — an infinite loop with no break. - Assuming string length equals character count. Strings iterate by code point, but
.lengthcounts UTF-16 units, so'👋'.lengthis2while[...'👋'].lengthis1.
You have an infinite iterator of prime numbers. You want the first ten. With an array you'd be stuck — you can't build an infinite array to then slice. But with an iterator you simply call next() ten times and stop. Now the deeper question: what would [...primes()] do? It would try to drain an endless source into an array and never return — freezing the tab. The lesson: laziness is a superpower and a loaded gun. The consumer, not the source, must decide where to stop.
Interview Corner
for...of actually do with it?"[Symbol.iterator]() method that returns an iterator — an object with a next() that yields { value, done }. for...of calls [Symbol.iterator]() once, then calls next() repeatedly, using each value until done is true."for...in?" → for...in enumerates property *keys* (including inherited); for...of walks an iterable's *values*.for...of and spread both work on it. What's the difference between the iterable and the iterator?"[Symbol.iterator]() method that returns a fresh iterator object holding its own state in a closure, so each traversal is independent. The iterable is the *factory* — it knows how to make cursors; the iterator is the *cursor* — it holds position and produces values. Returning a new iterator each call is what makes the iterable reusable across multiple loops."[Symbol.iterator]() returns this?" → Then object and iterator are one; it's single-use and can't be looped twice. Fine for streams, wrong for collections.for...of. How do you design it, and where does it bite?"next() pulls or computes one item at a time, so memory stays flat regardless of length — that's the whole reason iterators beat materialised arrays here. The traps are all consumer-side: any *eager* consumer — spread, Array.from, Promise.all — will try to drain the whole thing and hang on an infinite source, so bounded consumption (for...of with a break, or a take(n) wrapper) is mandatory. I'd also make the iterator single-use and document it, since replaying a live network or DB cursor isn't free."Symbol.iterator, which returns an iterator with next()…" Talk through the two protocols out loud as you code the custom iterable.take(n) that consumes it safely. They'll probe the boundary between eager and lazy consumers.for...of with for...in.- In the editor above, extend
rangeto take a thirdstepargument (range(0, 10, 2)→ 0 2 4 6 8). Verify it with bothfor...ofand spread. - Write a
take(iterable, n)function that returns an array of the firstnvalues — then run it onnaturals()to prove you can safely sample an infinite iterator. - Reproduce the one-shot gotcha: grab a raw iterator, spread it twice, and observe the second result is empty. Then fix a custom iterable so it *is* reusable, and explain what changed.
- Out loud (as in a micro1 screen), define "iterable" and "iterator" in one breath each, and say which one
for...ofasks for first.
Chapter Summary
- Two protocols interlock: an iterable has
[Symbol.iterator]()returning an iterator; an iterator hasnext()returning{ value, done }. - Iteration ends when
next()returnsdone: true, and a spent iterator stays spent — it's a forward-only cursor. Array,String,Map,Set,arguments, andNodeListare iterable out of the box; plain objects are not.for...of, spread, destructuring,Array.from,new Set/Map, andPromise.allall consume iterables by calling[Symbol.iterator]()under the hood.- You make any object iterable by hand — a
[Symbol.iterator]()returning an object withnext()— but it's verbose and easy to get subtly wrong. - Iterators are lazy: one value at a time, computed on demand, which makes infinite sequences legal and huge ones cheap.
- Return a fresh iterator each call for reusability; return
thisfor a single-use stream.
You've now seen the protocol — and also how fiddly it is to implement by hand: closures for state, manual { value, done } objects, easy mistakes around done and reusability. JavaScript agreed it was too much ceremony, and shipped a language feature whose entire job is to write correct iterators for you, with pausable functions that remember where they left off. Next chapter: Generators — where function* and yield turn everything you just did the hard way into a few lines.
Editorial reflection — the iteration protocol is a small idea with an outsized reach: one method name, one result shape, and suddenly arrays, strings, maps, sets, DOM lists, and anything you dream up all speak the same language to the same syntax. That is what good abstractions do — they don't add features, they remove special cases. Learn to see the protocol beneath the syntax, and
for...ofstops being a loop you memorised and becomes a contract you can extend.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.