What you'll learn
Quick Answer
A generator is a function declared with function* that can pause at each yield and resume later. Calling it runs no code at all: it returns a generator object, and the body only starts when you call .next(). Each .next() returns { value, done }. Because execution is lazy, generators can describe infinite sequences, stream paginated API results, and make any object iterable with for...of and the spread operator.
Calling a generator runs none of its code
A normal function starts executing the instant you call it. A generator function does not. Adding the star to function* changes what the call gives you back. Instead of a result, you get a generator object, and not one line of the body has run.
function* ids() {
console.log('generator started');
let n = 1;
while (true) {
yield n++;
}
}
const gen = ids(); // nothing is printed
console.log(gen.next()); // 'generator started', then { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }This is the failure mode that wastes an evening. Somebody puts an argument check at the top of a generator, calls the function with bad input, and no error appears. The check is real, it is simply sitting behind a pause that nobody has stepped past yet. If you want eager validation, validate in a plain wrapper function and return the generator from it.
function countFrom(start) {
if (typeof start !== 'number') {
throw new TypeError('start must be a number'); // throws immediately
}
return (function* () {
let n = start;
while (true) yield n++;
})();
}The second thing to internalise is that yield suspends the function and keeps everything alive. Local variables, the position inside a loop, the open try block, all of it stays on ice until the next .next(). A generator is a function with a save point.
Once the body runs to the end or hits a return, the generator is finished. Every further call to .next() gives { value: undefined, done: true }. Generators are single use. You cannot rewind one, so if you need the sequence twice, call the generator function again to get a fresh object. That single-use property is exactly why passing a generator to two different for...of loops quietly gives you an empty second loop.
The iterator protocol underneath for...of
for...of, the spread operator, Array.from and destructuring all speak one small protocol. An object is iterable if it has a method under the key Symbol.iterator that returns an iterator. An iterator is any object with a next() method that returns { value, done }. That is the whole contract.
You can implement it by hand to see the moving parts:
const marksRange = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
return current <= last
? { value: current++, done: false }
: { value: undefined, done: true };
}
};
}
};
console.log([...marksRange]); // [1, 2, 3]
for (const n of marksRange) console.log(n);That is fiddly, and you have to hold the cursor state yourself. A generator writes the same thing in three lines, because a generator object is already both an iterator and an iterable:
const marksRange = {
from: 1,
to: 3,
*[Symbol.iterator]() {
for (let i = this.from; i <= this.to; i++) yield i;
}
};
console.log([...marksRange]); // [1, 2, 3]This is why generators matter even if you never write .next() by hand. They are the cheapest way to make your own class or plain object work with for...of. A Playlist, a LinkedList, a Tree, a paginated result set: give each one a generator method under Symbol.iterator and every array-shaped syntax in the language starts working on it.
Worth knowing: strings, arrays, Map, Set, NodeList and the arguments object are all iterable. Plain objects are not, which is why for (const x of {a: 1}) throws a TypeError saying the value is not iterable. Use Object.entries() to get an iterable view of a plain object.
yield sends values out and takes values in
Most tutorials show yield only pushing values out. It also receives. The value you pass to .next(value) becomes the result of the yield expression that the generator is currently paused on.
function* enrolment() {
const name = yield 'What is your name?';
const city = yield `Hi ${name}, which city are you in?`;
return `${name} from ${city} enrolled`;
}
const chat = enrolment();
console.log(chat.next().value); // 'What is your name?'
console.log(chat.next('Anita').value); // 'Hi Anita, which city are you in?'
console.log(chat.next('Pune').value); // 'Anita from Pune enrolled'Notice that the argument to the very first .next() is thrown away. There is no yield waiting for it yet, so the generator has nowhere to put it. This two-way channel is the mechanism behind libraries like redux-saga, where your code yields a description of an effect and the library runs it and feeds the result back in.
A generator also has .return() and .throw(). Calling .return(v) forces the generator to finish, and .throw(err) raises an error at the paused yield so your try/catch inside the generator can handle it. The practical consequence is cleanup:
function* readRows(handle) {
try {
yield 'row 1';
yield 'row 2';
yield 'row 3';
} finally {
console.log('closing handle');
handle.close();
}
}
const fakeHandle = { close() { console.log('handle closed'); } };
for (const row of readRows(fakeHandle)) {
if (row === 'row 2') break;
}
// logs: 'closing handle', then 'handle closed'Breaking out of a for...of early calls .return() on the iterator, which runs the finally block. So a generator that owns a resource can release it correctly even when the consumer bails out. If you drive the generator manually with .next() and just stop calling it, that finally never runs, and the handle leaks.
Infinite sequences without infinite memory
Because nothing computes until you ask, a generator can describe a sequence with no end. The values are produced one at a time, so memory stays flat no matter how long the sequence conceptually is.
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* take(iterable, count) {
let i = 0;
for (const item of iterable) {
if (i++ >= count) return;
yield item;
}
}
console.log([...take(naturals(), 5)]); // [1, 2, 3, 4, 5]The failure mode here is loud and immediate. Write [...naturals()] and the spread operator will keep pulling values forever. The tab freezes and eventually the page runs out of memory. Any operation that has to reach done: true before it can finish, spread, Array.from, destructuring a rest element, or for...of without a break, will hang on an infinite generator. Always put a bounded operator like take in front of one.
Generators compose. yield* delegates to another iterable and yields everything it produces, which makes recursive traversal read naturally:
function* walk(node) {
yield node.value;
for (const child of node.children ?? []) {
yield* walk(child);
}
}
const tree = { value: 'root', children: [{ value: 'a', children: [{ value: 'b' }] }] };
console.log([...walk(tree)]); // ['root', 'a', 'b']Compare that with the version that builds an array with a results.push() at every level. The generator version never builds the intermediate arrays, and the caller can stop halfway through a huge tree by breaking out of the loop. Lazy is not just a memory trick, it also means you stop doing work as soon as you have what you need.
Where generators earn their place
Generators are not something you reach for daily, and that is fine. Here is where they genuinely pay off.
- Paginated APIs. An async generator turns page-by-page fetching into a single loop, and it stops fetching the moment the caller stops consuming.
- Custom iterables. One
*[Symbol.iterator]()method makes your class work withfor...of, spread and destructuring. - Tree and graph traversal.
yield*expresses depth-first walks without an explicit stack or an accumulator array. - Unique IDs and cycles. A tiny infinite generator for row IDs or for cycling through a colour palette reads better than a module-level counter.
- Test data. Generating a stream of fake student records lazily keeps fixtures small.
The async generator case is worth writing out, because it is the one you are most likely to use at work:
async function* fetchAllStudents(startUrl) {
let next = startUrl;
while (next) {
const res = await fetch(next);
const page = await res.json();
yield* page.results; // hand out each record
next = page.next; // null on the last page
}
}
for await (const student of fetchAllStudents('/api/students')) {
console.log(student.name);
if (student.city === 'Pune') break; // no further pages are requested
}Note for await...of, not for...of. An async generator exposes Symbol.asyncIterator and not Symbol.iterator, so a plain for...of loop does not hand you promises, it throws a TypeError saying the value is not iterable. Its .next() returns a promise of { value, done }, and for await...of is what awaits that for you.
Where generators do not belong: hot loops over ordinary arrays. Each .next() allocates a result object and involves a suspend and resume, so a straightforward for loop over an array in memory will be faster. Use generators when laziness, streaming or a custom iteration order is the point, not as a stylistic replacement for array methods you already have. In interviews the question is usually about the protocol, so being able to write Symbol.iterator by hand and explain { value, done } is what actually gets marked.
