What is symbol.iterator and why is it needed
Symbol.iterator - a built-in symbol in JavaScript that defines how an object produces a sequence of values for for...of, spread (...), Array.from(), and destructuring.
Theory
TL;DR
Symbol.iteratorworks like a vending machine's dispense slot: it defines the interface for getting values one at a time- Arrays, strings, Sets, and Maps have it built in; plain objects do not
- Any object with
[Symbol.iterator]()works infor...of, spread, and destructuring - The method must return an iterator: an object with
next()that returns{ value, done } - Need custom iteration order or lazy data loading? Implement it. Otherwise, use Array or Set directly
Quick example
// Plain object - no Symbol.iterator, throws TypeError
const obj = { a: 1, b: 2 };
for (const v of obj) console.log(v); // TypeError: obj is not iterable
// Add Symbol.iterator - now works everywhere
const iterable = {
data: [10, 20, 30],
*[Symbol.iterator]() { // generator handles next() for you
for (const v of this.data) yield v;
}
};
for (const v of iterable) console.log(v); // 10, 20, 30
console.log([...iterable]); // [10, 20, 30]The generator syntax (function*) handles the next() plumbing automatically. The result is the same as writing { next: () => ({ value, done }) } by hand.
How the iteration protocol works
for...of does three things. It calls obj[Symbol.iterator]() once to get an iterator. Then it calls iterator.next() on each loop step. It stops when next() returns { done: true }.
Two separate roles exist here. The iterable is the object with [Symbol.iterator](). The iterator is what that method returns: a stateful { next() } object. Arrays are both at once because arr[Symbol.iterator]() returns the array itself with next() attached.
That separation matters in practice. The iterator is stateful and exhausts once. The iterable produces a fresh iterator on every call.
Iterable vs iterator
const arr = [1, 2, 3];
const it = arr[Symbol.iterator](); // get a fresh iterator
console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }When to implement Symbol.iterator
Implement it when you have a data structure that should behave like a sequence: a range, a paginated result set, a tree traversal, or a DOM wrapper. Skip it if Array or Set already fits.
A practical signal: if you keep writing getAll() methods that return arrays, and callers always iterate the result, putting [Symbol.iterator] directly on the object is cleaner.
How the engine handles this
V8 checks obj[Symbol.iterator] at the start of for...of. If the property is missing or not callable, it throws TypeError: obj is not iterable before the loop body runs at all. Once it has the iterator, it calls next() in a loop, reading value and done from each result. Generators compile to state machines in V8 bytecode: each yield saves the execution position and local variables, and next() resumes from that saved point.
The lazy nature of iterators matters here. A generator that fetches from a network page won't hit the network until next() is actually called. No preloading, no full array allocated in memory.
Common mistakes
Reusing an exhausted iterator:
const it = myIterable[Symbol.iterator](); // cached iterator, not the iterable
for (const v of it) console.log(v); // 1, 2, 3
for (const v of it) console.log(v); // nothing - already exhaustedfor...of on an iterable calls [Symbol.iterator]() fresh each time. But it is already an iterator. Done is done. Fix: iterate the original object, not the cached iterator.
Mutating source during iteration:
const obj = {
data: [1, 2, 3],
*[Symbol.iterator]() { yield* this.data; }
};
for (const v of obj) {
obj.data.shift(); // mutates the live array mid-loop
console.log(v); // logs 1, 3 - skips 2
}The generator reads from a live reference. Clone data first if you need to mutate during the loop.
Using sync iteration on async generators:
async function* fetchItems() { yield await Promise.resolve(1); }
for (const v of fetchItems()) {} // TypeError
for await (const v of fetchItems()) {} // correctfor...of expects a sync next(). Async generators need for await...of and use [Symbol.asyncIterator] internally, not Symbol.iterator.
Infinite iterator without a break:
const counter = {
*[Symbol.iterator]() { let i = 0; while (true) yield i++; }
};
for (const n of counter) {
if (n > 10) break; // break triggers iterator.return() internally
console.log(n);
}
// Without the break, this never terminatesReal-world usage
- Node.js streams:
Readable.from(iterable)(Node 12+) converts any custom iterator to a readable stream directly Array.from: uses[Symbol.iterator]internally, same as spread- Destructuring:
const [a, b] = myIterablecalls[Symbol.iterator]under the hood Promise.all/Promise.race: both accept any iterable, not just arrays- Custom ranges:
for (const i of range(1, 100))reads better than a manualforloop with an index
I've found [Symbol.iterator] most useful on database cursor wrappers: when a DB client returns a cursor, attaching the iterator protocol to it lets downstream code stay clean without any interface changes.
Follow-up questions
Q: What is the difference between an iterable and an iterator?
A: An iterable has [Symbol.iterator]() that returns a fresh iterator on each call. An iterator is the stateful { next() } object. Arrays are both: arr[Symbol.iterator]() returns arr itself with next() attached.
Q: How would you implement a finite range without generators?
A: const range = (end) => ({ [Symbol.iterator]() { let i = 0; return { next: () => i < end ? { value: i++, done: false } : { done: true } }; } });
Q: Why does for...of throw on plain objects but for...in works?
A: for...in uses the internal property enumeration mechanism, separate from the iteration protocol. for...of strictly requires [Symbol.iterator]. Two different specs, two different checks.
Q: What happens when you break inside for...of over a generator?
A: The engine calls iterator.return() if it exists, which triggers the generator's finally block. This lets generators clean up resources on early exit: close file handles, cancel network requests.
Q: (Senior) How does V8 compile a generator function?
A: As a state machine. Each yield splits the function body into numbered states. next() jumps to the current state via bytecode dispatch, runs until the next yield, saves locals and the state index into the GeneratorObject closure, then suspends.
Examples
Range iterator with manual next()
// Explicit next() - no generator syntax
function range(start, end) {
return {
[Symbol.iterator]() {
let current = start;
return {
next() {
return current <= end
? { value: current++, done: false }
: { value: undefined, done: true };
}
};
}
};
}
for (const n of range(1, 5)) console.log(n); // 1 2 3 4 5
console.log([...range(1, 5)]); // [1, 2, 3, 4, 5]
const [first, second] = range(10, 20);
console.log(first, second); // 10 11All three usages - for...of, spread, and destructuring - call [Symbol.iterator]() internally. The same object works in all three contexts without any extra code.
Paginated API fetch with async generator
// Each yield sends one user - caller never sees pagination
async function* fetchAllUsers(baseUrl) {
let page = 1;
while (true) {
const res = await fetch(`${baseUrl}?page=${page++}&limit=10`);
const data = await res.json();
if (!data.length) return; // no more pages, done
yield* data; // stream items from this page one by one
}
}
// Process users without loading all pages into memory first
for await (const user of fetchAllUsers('/api/users')) {
console.log(user.name);
if (user.role === 'admin') break; // exits early, no extra fetch
}This pattern works in Express SSE routes and Next.js data layers where full preloading is not acceptable. Note: async generators use Symbol.asyncIterator, not Symbol.iterator. The for await...of loop handles that automatically.
Tree traversal as an iterable class
class TreeNode {
constructor(value, children = []) {
this.value = value;
this.children = children;
}
// Depth-first traversal via generator delegation
*[Symbol.iterator]() {
yield this.value;
for (const child of this.children) {
yield* child; // delegate to child's [Symbol.iterator]
}
}
}
const tree = new TreeNode(1, [
new TreeNode(2, [new TreeNode(4), new TreeNode(5)]),
new TreeNode(3)
]);
console.log([...tree]); // [1, 2, 4, 5, 3]yield* delegates to another iterable: it calls child[Symbol.iterator]() and exhausts it before continuing. This is generator delegation - it composes tree traversals cleanly without manual stack management.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.