Lesson 15 / 17
Generators
Pausable functions, the generator object's place in the iteration protocol, endless sequences, delegation, two-way communication, and lazy pipelines.
Contents
In the previous lesson, the same three pieces kept repeating in hand-written iterators:
the variable holding position, the end condition, and the self-returning
[Symbol.iterator] method. All three are mechanical and overshadow the function’s real
job.
A generator is a function that can pause its execution and resume later from where it
left off. It is defined in the function* form and pauses by giving a value with the
yield statement. When called, its body does not run; it returns an object satisfying
both of the protocol’s contracts. The lesson opens up this object’s behavior and reduces
the previous lesson’s lazy series to a few lines.
A Pausable Function
A generator function’s body does not run at all until the first next call. Every
next advances to the next yield statement and stops there.
let computeCount = 0; function* calibratedSeries(rawValues, offset) { for (const raw of rawValues) { computeCount += 1; yield Number((raw + offset).toFixed(2)); } } const generator = calibratedSeries([21.4, 19.8, 25.1], -0.4); console.log(computeCount); console.log(typeof generator.next); console.log(generator[Symbol.iterator]() === generator); console.log(JSON.stringify(generator.next())); console.log(computeCount); console.log(JSON.stringify(generator.next())); console.log(JSON.stringify(generator.next())); console.log(JSON.stringify(generator.next())); console.log(computeCount); console.log([...calibratedSeries([21.4, 19.8], -0.4)].join(",")); console.log(typeof calibratedSeries);
0
function
true
{"value":21,"done":false}
1
{"value":19.4,"done":false}
{"value":24.7,"done":false}
{"done":true}
3
21,19.4
function
The first line shows the body has not run at all. The third line confirms the generator
object is both iterator and iterable: it carries a [Symbol.iterator] method that
returns itself. All three pieces written by hand in the previous lesson are produced here
by the language itself.
The fifth line measures laziness: the first next has done only one computation. Once
the values run out, the done field becomes true and the value field stays
undefined. The last line fixes a distinction — the generator function itself is an
ordinary function; what is a generator is the object the call returns.
A generator object gets exhausted too. Traversing it again requires calling the function again; the spread in the tenth line sets up a new generator for exactly this.
Generator as a Class Method
A generator can also be defined in a class body by putting an asterisk before the method
name. The MeasurementSeries class from the previous lesson is shortened by half this
way.
class MeasurementSeries { constructor(sensor, values, offset = 0) { this.sensor = sensor; this.values = values; this.offset = offset; } *[Symbol.iterator]() { for (const raw of this.values) { yield Number((raw + this.offset).toFixed(2)); } } *labeledPairs() { let index = 0; for (const value of this) { yield [`${this.sensor}#${index}`, value]; index += 1; } } } const series = new MeasurementSeries("S-01", [21.4, 19.8, 25.1], -0.4); console.log([...series].join(",")); console.log([...series].join(",")); for (const [label, value] of series.labeledPairs()) { console.log(`${label} -> ${value}`); } console.log(Object.getOwnPropertyNames(MeasurementSeries.prototype).join(",")); console.log(Object.hasOwn(MeasurementSeries.prototype, Symbol.iterator)); console.log(new Map(series.labeledPairs()).get("S-01#1"));
21,19.4,24.7 21,19.4,24.7 S-01#0 -> 21 S-01#1 -> 19.4 S-01#2 -> 24.7 constructor,labeledPairs true 19.4
The series can be traversed twice, because what is being traversed is the series itself
and every traversal produces a new generator object. The labeledPairs method writes
for (const value of this) and benefits from its own class’s iterability; it does not
access the raw data a second time.
The seventh and eighth lines repeat the tie to the chain: generator methods also stand on
the prototype, and being symbol-keyed, they do not enter Object.keys output. The last
line shows the protocol’s unifying power — any iterable object giving key–value pairs can
be handed directly to the Map constructor.
Endless Sequences and Delegation
Because a generator only runs when requested, defining an endless sequence is no problem.
yield* lets a generator delegate its work to another iterable object.
function* increasingTime(start, step) { let time = start; while (true) { yield time; time += step; } } function* firstN(iterable, count) { if (count <= 0) return; let remaining = count; for (const value of iterable) { yield value; remaining -= 1; if (remaining <= 0) return; } } console.log([...firstN(increasingTime(1000, 60), 4)].join(",")); function* frontSeries() { yield 21.4; yield 19.8; } function* backSeries() { yield 25.1; } function* allSeries() { yield* frontSeries(); yield* backSeries(); yield* [18.2, 30.0]; return "done"; } const combined = allSeries(); console.log([...allSeries()].join(",")); let step = combined.next(); const values = []; while (!step.done) { values.push(step.value); step = combined.next(); } console.log(values.length); console.log(step.value);
1000,1060,1120,1180 21.4,19.8,25.1,18.2,30 5 done
increasingTime contains an infinite loop, but the program does not hang: values are
produced only when requested, and firstN stops after the fourth value. The infinite
loop being harmless is a direct result of laziness; producing the same measurement times
in an array would be impossible.
yield* accepts any kind of iterable object — another generator or an ordinary array.
The value of the return statement is skipped by for...of and spread; only code
that manually continues the next calls can see it, in the finishing step’s value
field. This is why generators are used to stream values, not to return a result.
Two-Way Communication
The argument given to a next call becomes the value of the yield expression the
generator is paused at. This way, a generator does not just produce values, it also
receives values from outside.
function* thresholdMonitor(startThreshold) { let threshold = startThreshold; const exceeding = []; try { while (true) { const incoming = yield `threshold=${threshold}, exceeding=${exceeding.length}`; if (typeof incoming === "number") { if (incoming > threshold) exceeding.push(incoming); } else if (typeof incoming === "object" && incoming !== null) { threshold = incoming.newThreshold; } } } finally { console.log(`closing: total exceeding=${exceeding.length}`); } } const monitor = thresholdMonitor(20); console.log(monitor.next("this value is ignored").value); console.log(monitor.next(21.4).value); console.log(monitor.next(19.8).value); console.log(monitor.next({ newThreshold: 25 }).value); console.log(monitor.next(25.1).value); console.log(JSON.stringify(monitor.return("manually stopped"))); console.log(JSON.stringify(monitor.next(30)));
threshold=20, exceeding=0
threshold=20, exceeding=1
threshold=20, exceeding=1
threshold=25, exceeding=1
threshold=25, exceeding=2
closing: total exceeding=2
{"value":"manually stopped","done":true}
{"done":true}
The first next call’s argument is ignored: the body has not yet paused at any yield
expression, so there is no place to receive a value. In subsequent calls, the value sent
falls into the incoming variable.
The return method ends the generator from outside and runs the finally block — this
is the generator counterpart of the previous lesson’s cleanup point. A finished generator
answers as done on subsequent next calls. The throw method similarly raises an error
at the point the generator is paused.
Compared with the counter in the Closures lesson, the difference is clear: a closure holds state but does not hold execution. A generator, alongside state, also stores where in the program it left off.
A Lazy Pipeline
Generators’ most concrete gain is that transformation chains can be built without producing intermediate arrays. When filtering and mapping are each written as a generator, every value passes through the entire pipeline one at a time.
let readCount = 0; let transformCount = 0; function* rawSource(values) { for (const value of values) { readCount += 1; yield value; } } function* filterGen(iterable, predicate) { for (const value of iterable) { if (predicate(value)) yield value; } } function* mapGen(iterable, transform) { for (const value of iterable) { transformCount += 1; yield transform(value); } } function* firstN(iterable, count) { if (count <= 0) return; let remaining = count; for (const value of iterable) { yield value; remaining -= 1; if (remaining <= 0) return; } } const raw = [21.4, 19.8, 25.1, 18.2, 30.0, 27.3, 15.0]; const pipeline = firstN( mapGen( filterGen(rawSource(raw), (d) => d > 20), (d) => Number((d * 1.8 + 32).toFixed(1)), ), 2, ); console.log(readCount); console.log([...pipeline].join(",")); console.log(readCount); console.log(transformCount); const withArrays = raw .filter((d) => d > 20) .map((d) => Number((d * 1.8 + 32).toFixed(1))) .slice(0, 2); console.log(withArrays.join(","));
0 70.5,77.2 3 2 70.5,77.2
The two paths give the same result; the work they do is different. The generator pipeline reads only three of seven raw values and does two transforms; it stops the moment the two needed results are obtained. The version written with array methods first filters all five values, then transforms all five, then takes the first two — and produces two intermediate arrays along the way.
The difference sharpens as the data source grows or the transform gets more expensive. The generator setup, in exchange, has a fixed cost: several execution passes happen per value. On small arrays, array methods are both shorter and faster; the criterion is the number of unprocessed elements.
Summary
- A generator function’s body does not run when called; it returns a generator object that is both iterator and iterable.
- Every
nextcall advances to the nextyieldstatement; execution pauses there and state is preserved. - A starred method can be defined in a class body; generator methods stand on the prototype too.
yield*delegates work to another iterable object; thereturnstatement’s value is visible only in manually continuednextcalls.- The
nextargument becomes the value of theyieldexpression the generator is paused at; thereturnmethod performs cleanup by running thefinallyblock. - A pipeline built from generators produces no intermediate array and processes only as many values as requested.
Next Step
All the collections in this topic have held arbitrary JavaScript values: numbers, strings, objects. When a measurement stream needs to be written to a file or sent over a network, though, the data has to be represented as a byte sequence. The next lesson takes up fixed-size binary buffers and the typed views over them; byte order and overflow behavior will be tested there too.
To keep your progress and take notes, Log in
My notes
Log in to take notes.