JavaScript Iterators and Generators: Symbol.iterator, yield and Iterator Helpers
We cover the iterable and iterator protocols, writing your own iterable with Symbol.iterator, how generators and yield pause and resume, infinite sequences, ES2025 iterator helpers and ES2026's Iterator.concat.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
A function with a star, two yields and a few console.logs. What does this code print to the console, and in what order?
function* numbers() {
console.log("started");
yield 1;
yield 2;
}
const it = numbers();
console.log("ready");
console.log(it.next());
console.log([...it]);
Make a guess before you keep reading. Think about two things in particular: when does "started" get printed, and how many elements end up in the array on the last line?
Two Protocols: Iterable and Iterator
Earlier we looped over arrays, strings, Maps and Sets with for...of. Spread, array destructuring and Array.from accepted the same structures. All of them rest on a single contract, and that contract has two parts.
An iterable is an object that carries a method under the Symbol.iterator key. When called, that method returns an iterator. An iterator is an object with a next() method; each call gives back a result shaped like { value, done }. When done is true, it's finished. Think of the ticket machine at a bank branch: the machine itself is the iterable, it can start a fresh queue for each customer. The pad of tickets in your hand is the iterator; every press gives you the next number, and when the pad runs out it says "none left."
Running the protocol by hand shows what for...of does behind the scenes. Try it in the console:
const letters = ["a", "b"];
const iterator = letters[Symbol.iterator]();
console.log(iterator.next()); // { value: "a", done: false }
console.log(iterator.next()); // { value: "b", done: false }
console.log(iterator.next()); // { value: undefined, done: true }
That's exactly what for...of does: it calls Symbol.iterator, keeps calling next() until done is true, and hands each value to the loop variable. Plain objects don't implement this protocol; if you write for (const x of { a: 1 }), you get a TypeError saying the object isn't iterable.
Writing Your Own Iterable
Because the protocol is simple, you can make any object iterable. Let's make an object that holds a range of numbers loopable with for...of:
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
};
console.log([...range]); // [1, 2, 3, 4, 5]
for (const n of range) console.log(n);
Notice that the range object itself doesn't count. Every call to Symbol.iterator produces a new iterator with its own current variable. That's why you can loop over range as many times as you like; every loop starts from the beginning. You can also add a return() method to the iterator if you want: when a loop ends early with break or an error, for...of calls it and gives you a chance to clean up.
Generator Functions: function* and yield
The code above works, but it's a bit ceremonial: an object, a method inside it, an object returned by that method, and another method inside that. Generators let you write the same thing much more naturally:
const range2 = {
from: 1,
to: 5,
*[Symbol.iterator]() {
for (let n = this.from; n <= this.to; n++) {
yield n;
}
}
};
console.log([...range2]); // [1, 2, 3, 4, 5]
A generator function is declared with function*. Calling it doesn't run the body; it returns a paused generator object. On each next() call, the body runs up to the next yield, hands over the value next to yield, and stops there. Meanwhile the engine keeps all of the function's local variables and the spot where it left off; the next next() continues from exactly that point. A normal function enters once and exits once. A generator is more like a bookmark that remembers where you stopped reading.
Now back to the opening question. The output order is: "ready", "started", { value: 1, done: false } and [2]. Calling numbers() didn't run the body at all, so "ready" came first. The first next() started the body, "started" was printed, and it stopped at the first yield. Spread then consumed the iterator from where it was to the end; only 2 was left. A generator object is both an iterator and an iterable, its Symbol.iterator returns itself, and that's why spread accepts it.
Generators have two more handy features. yield* hands over all the values of another iterable in order. next(value) sends a value into the generator; the value you send becomes the result of the paused yield expression:
function* inner() {
yield "b";
yield "c";
}
function* outer() {
yield "a";
yield* inner();
yield "d";
}
console.log([...outer()]); // ["a", "b", "c", "d"]
function* dialog() {
const name = yield "What's your name?";
yield `Hello, ${name}!`;
}
const chat = dialog();
console.log(chat.next().value); // "What's your name?"
console.log(chat.next("Ada").value); // "Hello, Ada!"
Infinite Sequences and Lazy Evaluation
Because a generator produces values on demand, you can describe a sequence that never ends. There's no array in memory; there's only the knowledge of how to produce the next value. This is called lazy evaluation:
function* fibonacci() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
for (const n of fibonacci()) {
if (n > 100) break;
console.log(n);
}
// 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
while (true) may look scary, but it's fine; the loop stops at every yield and waits for you to call next() to continue. When break arrives, for...of calls the generator's return() method and the generator closes. If the generator has a try...finally inside, the finally block runs at that moment; it's the right place to close any resource you opened:
function* withCleanup() {
try {
yield 1;
yield 2;
} finally {
console.log("cleaned up");
}
}
for (const x of withCleanup()) {
console.log(x);
break;
}
// 1
// "cleaned up"
Iterator Helpers: map, filter, take and Friends
We're used to map and filter on arrays. But how do you take the squares of the first five odd numbers of an infinite generator? You can't turn it into an array, because the array would never end. The iterator helpers that came with ES2025 fill exactly this gap. Iterator.prototype, the shared prototype of all iterators, gained the methods map, filter, take, drop, flatMap, reduce, toArray, forEach, some, every and find. They work in all current versions of Chrome, Edge, Firefox and Safari:
function* naturals() {
let n = 1;
while (true) yield n++;
}
const squares = naturals()
.filter((n) => n % 2 === 1)
.map((n) => n * n)
.take(5)
.toArray();
console.log(squares); // [1, 9, 25, 49, 81]
Their biggest difference from array methods is that they're lazy. An array's map walks the whole array and produces a new one, then filter walks that array from the start. Iterator helpers carry each value through to the end of the chain one at a time, and stop once enough values have been collected. See it for yourself:
const result = [1, 2, 3, 4].values()
.map((n) => {
console.log("map", n);
return n * 2;
})
.filter((n) => {
console.log("filter", n);
return n > 2;
})
.take(1)
.toArray();
console.log(result);
// map 1, filter 2, map 2, filter 4, [4]
3 and 4 were never processed; take(1) closed the chain once it got the first matching value. Notice that to reach the helpers on an array we had to get an iterator with values(); [1, 2, 3].map is still the array's own, eager map. Since the keys(), values() and entries() methods of a Map already return iterators, the helpers work on them directly.
There's also Iterator.from. The range iterator we wrote by hand above doesn't inherit from Iterator.prototype, so it doesn't have the helpers. Iterator.from(range) wraps any iterable or iterator into a real iterator that has them: Iterator.from(range).map((n) => n * 10).toArray() gives you [10, 20, 30, 40, 50].
Iterator.concat: Joining Several Sources End to End
Iterator.concat, which came with ES2026, joins several iterables in order into a single iterator. Its difference from the arrays' concat is, again, laziness: it copies nothing, and walks each source only when that source's turn comes. The example uses the naturals generator from the previous section.
const all = Iterator.concat(
["a", "b"],
new Set(["c"]),
naturals().take(2)
);
console.log([...all]); // ["a", "b", "c", 1, 2]
Notice that the last source is a slice cut from an infinite generator with take(2); when concat got to it, it pulled only two values. One detail: the arguments have to be objects. If you write Iterator.concat("ab"), you get a TypeError because a string is a primitive; turn the string into an array first with [..."ab"]. Iterator.concat has been in all major browsers since spring 2026, so it's quite new; if you also need to support older versions, check that it exists first, or do the same job with a small generator that uses yield*.
Common Pitfalls
Trying to use an iterator twice. An iterator is single-use; once exhausted, it always returns done: true. If you write const gen = numbers(); [...gen]; [...gen];, the second spread gives an empty array. Arrays and iterables like range, on the other hand, produce a new iterator every time, so you can loop over them again and again. If you need to walk something more than once, keep the iterable or the function that produces it, not the iterator.
Spreading an infinite generator. [...naturals()] never finishes and locks up the tab. Always limit infinite sources with take, break or a condition.
Mistaking the result of a helper chain for an array. naturals().map(fn) isn't an array but a new iterator; when you print it to the console, you see an object, not values. If you want the values, consume the chain with toArray(), for...of or spread.
Trying to write a generator as an arrow function. Arrow functions have no generator version. In the method shorthand you write *name() {}, and for a function function* name() {}.
A Small Experiment
★☆☆ Predict before running: function* g() { yield 1; return 2; yield 3; } console.log([...g()]); Then call next() four times by hand on the same generator and see where the value of return shows up.
★★☆ Write a chunks(iterable, size) generator that splits an iterable into pieces of the given size. [...chunks([1, 2, 3, 4, 5], 2)] should give [[1, 2], [3, 4], [5]]. Then use it with naturals() and the take helper to get the first three chunks.
★★★ Write a *walk(node) generator that walks a tree-shaped object (each node has a name and a children array) depth-first; use yield*. Then use iterator helpers to find the first two nodes whose name starts with "a", and prove with console.log that the walk never even enters the rest of the tree.
Every value in this post was ready to go; the generator produced them synchronously, whenever we asked. But what if each value comes from the network, after a wait of a few hundred milliseconds? In the next post we move on to the iterator's asynchronous sibling: Symbol.asyncIterator, async generators, for await...of and reading a paginated API like a stream with a single loop.