Iteration, Data & Functional JS

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.

The question this chapter answers

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 }.)

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

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...of loops
  • spread into an array or call: [...it], fn(...it)
  • array/Set/Map destructuring: const [first, second] = it
  • Array.from(it), new Set(it), new Map(it)
  • Promise.all, Promise.race, Promise.allSettled, Promise.any
  • yield* delegation inside a generator

So when you make your own object iterable, all of that lights up at once — for free.

Everyday analogy

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():

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

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.

History changed here

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:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

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.

The engineer's notebook

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:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

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.

Real-world example — chika.ng Academy

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...in with for...of. for...in walks enumerable keys (and inherited ones); for...of walks values of an iterable. Using for...in on 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]. Iterate Object.keys/values/entries(obj) instead (those return arrays, which are iterable).
  • Reusing an exhausted iterator. After a full for...of or spread, a raw iterator is spent. Get a fresh one from the iterable for a second pass.
  • Returning this from [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 whose next() never returns { done: true } will hang any for...of, spread, or Promise.all that consumes it — an infinite loop with no break.
  • Assuming string length equals character count. Strings iterate by code point, but .length counts UTF-16 units, so '👋'.length is 2 while [...'👋'].length is 1.
Thought experiment

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

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"What makes an object iterable, and what does for...of actually do with it?"
Model answer — "An object is iterable if it has a [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."
Follow-up they'll throw: "How's that different from for...in?" → for...in enumerates property *keys* (including inherited); for...of walks an iterable's *values*.
Intermediate
"Make this object iterable so for...of and spread both work on it. What's the difference between the iterable and the iterator?"
Model answer — "I'd add a [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."
Follow-up: "What if [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.
Senior
"You must stream a huge — possibly unbounded — data source through code that expects for...of. How do you design it, and where does it bite?"
Model answer — "I model the source as a lazy iterable: 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."
The senior signal: naming *who decides when to stop* (the consumer), and knowing which consumers are eager vs. lazy. That's a systems answer wearing a protocol costume.
Company lens — how the same topic gets asked
micro1Live and conversational — the AI interviewer wants you narrating: "an iterable exposes Symbol.iterator, which returns an iterator with next()…" Talk through the two protocols out loud as you code the custom iterable.
GoogleExpect the lazy/infinite angle: implement an unbounded iterator, then a take(n) that consumes it safely. They'll probe the boundary between eager and lazy consumers.
MicrosoftValues clean protocol reasoning — cleanly separate iterable from iterator and explain reusability vs. one-shot, ideally contrasting for...of with for...in.
Amazon / AWSOperational lens: "stream millions of records without OOM." Tie the protocol to memory footprint and backpressure — one value at a time, computed on demand.
Hands-on exercise
  1. In the editor above, extend range to take a third step argument (range(0, 10, 2) → 0 2 4 6 8). Verify it with both for...of and spread.
  2. Write a take(iterable, n) function that returns an array of the first n values — then run it on naturals() to prove you can safely sample an infinite iterator.
  3. 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.
  4. Out loud (as in a micro1 screen), define "iterable" and "iterator" in one breath each, and say which one for...of asks for first.

Chapter Summary

  • Two protocols interlock: an iterable has [Symbol.iterator]() returning an iterator; an iterator has next() returning { value, done }.
  • Iteration ends when next() returns done: true, and a spent iterator stays spent — it's a forward-only cursor.
  • Array, String, Map, Set, arguments, and NodeList are iterable out of the box; plain objects are not.
  • for...of, spread, destructuring, Array.from, new Set/Map, and Promise.all all consume iterables by calling [Symbol.iterator]() under the hood.
  • You make any object iterable by hand — a [Symbol.iterator]() returning an object with next() — 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 this for a single-use stream.
Narrative bridge

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...of stops 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.