Up to now you have looped over arrays with for...of and spread them with ... . That works because JavaScript's built-in types secretly speak a common language called the iterator protocol. But what happens when you build your own data structure — a deck of cards, a range of numbers, a paginated result — and try to loop over it? Without the protocol, for...of throws a TypeError and you are back to manual indexing, which is brittle and easy to get wrong by one.
The iterator protocol is the bridge. Any object that implements it becomes loopable. It is not magic baked into arrays alone; it is a contract you can teach your own objects to fulfill. Once they do, every tool that speaks the protocol — for...of, the spread operator, Array.from, destructuring — starts working on your custom types too. You write the protocol once, and the entire ecosystem of looping tools understands your object immediately.
flowchart LR
O["Object with<br/>Symbol.iterator"] --> I["Iterator<br/>{ next() }"]
I --> R["{ value, done }"]
R --> C{"done?"}
C -- No --> I
C -- Yes --> E["Loop ends"]
style O fill:#e0900b,color:#fff
style I fill:#3776ab,color:#fff
style E fill:#1e7f3d,color:#fff
The anatomy of the protocol
The protocol has three pieces. First, the object must have a method keyed by the special built-in Symbol.iterator. That method returns an iterator object. Second, the iterator must have a next() method. Third, every call to next() returns an object with two properties: value, the current item, and done, a boolean that is true when there is nothing left to give. No inheritance is required; the engine simply checks for these exact names and shapes.
When a for...of loop begins, it looks for [Symbol.iterator] on the right-hand side. It calls that method once to get the iterator, then repeatedly calls next() until done becomes true. The value from each step is assigned to your loop variable. This is why [1, 2, 3] and "abc" both work with for...of: they each provide their own iterator behind the scenes. Strings iterate over Unicode code points, arrays over indices, but the loop itself never needs to know the difference.
const range = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
};
console.log([...range]);
for (const n of range) {
console.log(n);
}
Generators: iterators without the boilerplate
Writing [Symbol.iterator] by hand is instructive, but it is also tedious. You must manage the current state, the termination condition, and the return object manually. For most real code you will reach for a generator function instead. A generator is a special kind of function that can pause its execution, yield a value to its caller, and resume later when the caller asks for the next item. This pause-and-resume behavior makes it ideal for infinite sequences, lazy calculations, or consuming data that arrives a chunk at a time.
You declare one with the function* keyword. Inside, yield produces a value and freezes the function mid-sentence. The next time the iterator's next() method is called, the function thaws and continues from exactly where it left off, with all its local variables intact. This gives you the power of a hand-rolled iterator with almost no boilerplate.
flowchart TD
G["function* gen()"] --> Y1["yield 1"]
Y1 --> P1["Paused"]
P1 --> N1["next() called"]
N1 --> Y2["yield 2"]
Y2 --> P2["Paused"]
P2 --> N2["next() called"]
N2 --> D["return { done: true }"]
style G fill:#e0900b,color:#fff
style P1 fill:#3776ab,color:#fff
style P2 fill:#3776ab,color:#fff
style D fill:#1e7f3d,color:#fff
function* countUp(start, end) {
let n = start;
while (n <= end) {
yield n;
n++;
}
}
console.log([...countUp(1, 3)]);
const it = countUp(5, 5);
console.log(it.next().value);
console.log(it.next().done);
Delegation with yield*
A generator can also delegate to another generator with yield*. This is useful when a complex iterable is built from simpler ones. The outer generator yields every item from the inner generator before continuing. The rest of the language does not need to know there are two generators involved; it sees a single, flat sequence. You can use this to split a large data pipeline into small, testable stages.
Because generators are iterators, you can use them anywhere the protocol is expected: in for...of loops, with the spread operator, or by passing them to Array.from. The function* keyword is doing nothing more than building the { next() } object for you, but the result behaves identically to a hand-written one. That is why generator-based code composes so cleanly with the rest of the language.
function* inner() { yield 'a'; yield 'b'; }
function* outer() { yield 'start'; yield* inner(); yield 'end'; }
console.log([...outer()]);
flowchart LR F["for...of loop"] --> S["obj[Symbol.iterator]()"] S --> N["iterator.next()"] N --> V["value assigned<br/>to loop variable"] V --> N N -.->|"done: true"| E["Loop ends"] style F fill:#e0900b,color:#fff style S fill:#3776ab,color:#fff style E fill:#1e7f3d,color:#fff
What does this print? Calling next() drives the generator forward one step at a time.
function* gen() { yield 1; yield 2; }
const g = gen();
console.log(g.next().value);
console.log(g.next().done);
The first next() advances to the first yield and returns { value: 1, done: false }.
The second next() advances to the second yield and returns { value: 2, done: false }, so done is false.
Write a generator function range(start, end) that yields every integer from start up to and including end.
function* range(start, end) {
// yield each integer from start to end inclusive
}
Use a while loop that continues while n <= end.
Yield n, then increment it.
function* range(start, end) {
let n = start;
while (n <= end) {
yield n;
n++;
}
}
This function is meant to be a generator that yields the numbers 1, 2, and 3. But it uses a normal function and return, so only the first value comes back and the function exits. Fix it so all three numbers are yielded.
function count123() {
return 1;
return 2;
return 3;
}
Use function* to declare a generator.
Use yield instead of return so the function pauses rather than exits.
function* count123() {
yield 1;
yield 2;
yield 3;
}
What does this print? Remember that after the last yield, one more next() returns done: true with no value.
function* g() { yield 'a'; }
const it = g();
console.log(it.next().value);
console.log(it.next().done);
The first next() returns the yielded value 'a'.
The second next() reaches the end of the generator, so done becomes true.
Write createCountdown(n) that returns an object which is iterable using the iterator protocol (not a generator). When looped over with for...of, it should count down from n to 1. For example, [...createCountdown(3)] produces [3, 2, 1].
function createCountdown(n) {
// return an iterable object with [Symbol.iterator]
}
Return an object with a [Symbol.iterator] method.
The iterator's next() returns { value: count--, done: false } while count > 0.
function createCountdown(n) {
return {
[Symbol.iterator]() {
let count = n;
return {
next() {
if (count > 0) {
return { value: count--, done: false };
}
return { done: true };
}
};
}
};
}
Recap
- The iterator protocol requires
[Symbol.iterator]()to return an object with anext()method. next()must return{ value, done }; the loop ends whendoneis true.- Generator functions (
function*) automatically implement the protocol. yieldpauses execution and produces a value; the generator resumes on the nextnext()call.yield*delegates to another iterable, flattening it into the current sequence.- Calling a generator function returns an iterator object, not a value — drive it with a loop or spread.
- Any object that speaks the protocol works with for...of, spread, Array.from, and destructuring.
Next you will meet Symbols — unique keys that let you attach hidden properties to objects without the risk of name collisions.