“Make this Range class work with for...of and the spread operator.” Or, in its trickier form: “Why does my custom collection work in the first loop but come out empty in the second?” Interviewers ask about the iterator protocol because it sits underneath half of modern JavaScript syntax: for...of, spread, array destructuring, Array.from, Promise.all, new Map(entries) and yield* all speak it. Knowing the protocol shows you understand the language’s contracts rather than just its keywords.
The second form of the question catches people who confuse an iterable (something you can ask for an iterator) with an iterator (a one-way cursor).
Before you start
You should be comfortable with classes or object literals with methods, closures, and what a Symbol is. You do not need to know generators in depth, but having seen function* will help for the shorter implementations later.
The short answer
An object is iterable if it has a method under the key Symbol.iterator that returns an iterator. An iterator is an object with a next() method returning { value, done }; when done is true, iteration stops. for...of, spread and destructuring call [Symbol.iterator]() once, then call next() until done. Return a fresh iterator from each [Symbol.iterator]() call so the collection can be iterated more than once; a generator method does that automatically.
How it works
There are two separate roles. The iterable is the collection; the iterator is the cursor over it. Arrays, strings, Map, Set, arguments and NodeLists are iterable. Plain objects are not.
const letters = ['a', 'b'];
const it = letters[Symbol.iterator]();
console.log(it.next()); // { value: 'a', done: false }
console.log(it.next()); // { value: 'b', done: false }
console.log(it.next()); // { value: undefined, done: true }
try {
for (const x of { a: 1 }) console.log(x);
} catch (e) {
console.log(e.message); // {(intermediate value)} is not iterable
}Built-in iterators are also iterable themselves: their [Symbol.iterator]() returns this. That lets you pass an iterator anywhere an iterable is expected, but it is also why iterators are single-use. Once exhausted, asking for “a new iterator” just gives you the same exhausted object.
const entries = new Map([['x', 1], ['y', 2]]).entries();
console.log([...entries]); // [ [ 'x', 1 ], [ 'y', 2 ] ]
console.log([...entries]); // []The protocol also has an optional return() method, which consumers call when they stop early (on break or when destructuring takes fewer values). It lets an iterator release resources.
Step-by-step walkthrough
Step 1: Implement the iterator by hand
Writing next() yourself shows exactly what the protocol requires. Each call to [Symbol.iterator]() creates new cursor state in a closure.
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current < end) return { value: current++, done: false };
return { value: undefined, done: true };
},
};
}
}
const r = new Range(1, 4);
console.log([...r]); // [ 1, 2, 3 ]
console.log([...r]); // [ 1, 2, 3 ]
const [first, second] = r;
console.log(first, second); // 1 2Because the cursor lives inside [Symbol.iterator](), the range can be spread twice.
Step 2: Replace it with a generator
A generator function returns an object that is both an iterator and an iterable, with next, return and throw already implemented. Using a generator method for [Symbol.iterator] gives the same behaviour in three lines.
class Range {
constructor(start, end, step = 1) {
Object.assign(this, { start, end, step });
}
*[Symbol.iterator]() {
for (let n = this.start; n < this.end; n += this.step) yield n;
}
}
console.log(Array.from(new Range(0, 10, 3))); // [ 0, 3, 6, 9 ]Step 3: Support early exit with return()
If the iterator holds something that needs releasing, implement return(). Consumers call it on break, on a thrown error inside the loop, and when destructuring stops early. It must return an object, conventionally { done: true }.
function tracked(items) {
let i = 0;
return {
[Symbol.iterator]() { return this; },
next() {
return i < items.length ? { value: items[i++], done: false } : { value: undefined, done: true };
},
return() {
console.log('released at', i);
return { value: undefined, done: true };
},
};
}
for (const x of tracked(['a', 'b', 'c'])) {
if (x === 'b') break;
}
// released at 2
const [only] = tracked([10, 20]);
// released at 1Note that return() is not called when the iterator runs to completion on its own; there is nothing left to stop.
Step 4: Build lazy pipelines
Because iterators produce values on demand, you can chain transformations without building intermediate arrays, and even work with infinite sequences. Node 22 and current browsers ship iterator helpers such as .map, .filter and .take on iterator objects.
function* naturals() {
let n = 1;
while (true) yield n++;
}
const firstSquares = naturals()
.map((n) => n * n)
.filter((n) => n % 2 === 1)
.take(3)
.toArray();
console.log(firstSquares); // [ 1, 9, 25 ]Without lazy evaluation, naturals() would never finish. If your runtime lacks iterator helpers, the same pipeline is a few lines of generator functions.
Worked scenario
A team built a Playlist class and stored its iterator as an instance field to “avoid allocating.” The first render listed every song; the second render, after a sort button was clicked, showed an empty list.
class PlaylistBroken {
constructor(songs) {
this.songs = songs;
this.cursor = songs[Symbol.iterator]();
}
[Symbol.iterator]() {
return this.cursor;
}
}
const broken = new PlaylistBroken(['Intro', 'Outro']);
console.log([...broken].length); // 2
console.log([...broken].length); // 0The class was handing out the same exhausted cursor every time. The fix returns a new iterator from each call, which is what consumers assume when they receive an iterable.
class Playlist {
constructor(songs) {
this.songs = [...songs];
}
*[Symbol.iterator]() {
yield* this.songs;
}
}
const list = new Playlist(['Intro', 'Outro']);
console.log([...list].length, [...list].length); // 2 2Copying songs in the constructor also stops callers from mutating the internal array through the reference they passed in. If allocation really matters, measure first; a generator object is tiny.
Common mistake
- “Objects are iterable.” Plain objects are not; use
Object.entries(obj)or aMap. - Confusing iterable and iterator. An iterable produces iterators; an iterator is a cursor. Returning a shared iterator from
[Symbol.iterator]()makes a collection single-use. - Using
for...inon arrays. It iterates enumerable string keys (including inherited ones), not values.for...ofuses the protocol. - Spreading an infinite iterator.
[...naturals()]never finishes. Take a bounded slice first. - Forgetting
{ done: true }eventually. An iterator that never reportsdonemakes every consumer loop forever. - Mutating an array while iterating it. The array iterator reads the live length on every
next(), so pushing inside the loop can make it run forever.
Verify the behavior
These tests prove the class is reusable, works with every protocol consumer, and receives return() on early exit.
import test from 'node:test';
import assert from 'node:assert/strict';
class Range {
constructor(a, b) { this.a = a; this.b = b; }
*[Symbol.iterator]() { for (let n = this.a; n < this.b; n++) yield n; }
}
test('works with spread, destructuring and Array.from', () => {
const r = new Range(1, 4);
assert.deepEqual([...r], [1, 2, 3]);
const [x, , z] = r;
assert.deepEqual([x, z], [1, 3]);
assert.deepEqual(Array.from(r, (n) => n * 10), [10, 20, 30]);
});
test('can be iterated twice', () => {
const r = new Range(0, 2);
assert.deepEqual([...r], [...r]);
});Run node --test iterable.test.mjs; both tests pass.
Follow-up questions
What is the async version of the protocol? Symbol.asyncIterator, whose iterator’s next() returns a promise of { value, done }. for await...of consumes it.
What does yield* do? It delegates to another iterable, yielding each of its values and forwarding next, throw and return calls.
Why is [Symbol.iterator]() { return this; } on iterators useful? It lets an iterator be passed to anything expecting an iterable, such as for...of or Array.from.
Which built-ins accept any iterable? Array.from, new Map, new Set, Promise.all and its siblings, Object.fromEntries, and spread in arrays and function calls.
Interview exercise
Write a function zip(a, b) that takes any two iterables and returns an iterable of pairs, stopping at the shorter input. It must work with infinite iterables and must release the longer input’s iterator when it stops.
Answer and reasoning
function* zip(a, b) {
const ia = a[Symbol.iterator]();
const ib = b[Symbol.iterator]();
try {
while (true) {
const x = ia.next();
const y = ib.next();
if (x.done || y.done) return;
yield [x.value, y.value];
}
} finally {
ia.return?.();
ib.return?.();
}
}
function* naturals() { let n = 0; while (true) yield n++; }
console.log([...zip(['a', 'b', 'c'], naturals())]);
// [ [ 'a', 0 ], [ 'b', 1 ], [ 'c', 2 ] ]It accepts any iterable because it only uses [Symbol.iterator]() and next(). It pulls one value from each side per step, so an infinite input is fine: nothing is buffered. When either side finishes, or when the consumer of zip breaks early (which calls return() on the generator and runs its finally), both inner iterators get their optional return() called so generators or resource-holding iterators can clean up. Calling return() on an already finished iterator is harmless, and the ?. handles iterators that do not implement it.
Continue learning
Find more JavaScript interview questions and practise with the JavaScript MCQs. Related notes: generator cleanup on early exit and well-known symbols. Official reference: Iteration protocols on MDN.