JavaScript / Intermediate: modules and asynchronous contracts / Lesson 11
Iteration protocols and lazy sequences
Separate iterable data from the cursor that consumes it. Build bounded generators and reason about laziness, exhaustion, early exit, and collection semantics.
On this page
Lesson goals
- Identify iterable and iterator contracts.
- Trace generator suspension and exhaustion.
- Bound consumption and release owned resources.
Iteration is a protocol, not just an array loop
An iterable supplies a method at Symbol.iterator that returns an iterator. The iterator’s next method returns an object describing a value and whether iteration is done. for...of consumes this protocol, while for...in enumerates enumerable property keys, including inherited keys in relevant cases; it is not a substitute for value iteration. Arrays, strings, maps, and sets provide useful iterable behavior, but a plain record is not automatically iterable. Choose Object.entries for explicit own key/value pairs when that is the intended record contract. Avoid adding an iterator merely to conceal an unclear data model.
A generator produces work on demand
Calling a generator function creates an iterator without running its body to completion. next advances to the next yield, and local state remains available between resumptions. This supports sequences whose values are produced as needed rather than allocated all at once. The example yields exactly three synthetic page numbers. The final return value marks completion but is not included by for...of or array spread as a yielded item. Laziness controls when work happens; it does not guarantee low memory if the consumer immediately spreads a huge or unbounded sequence into an array.
function* pages(limit) {
for (let page = 1; page <= limit; page++) yield page;
}
const cursor = pages(3);
console.log(cursor.next().value);
console.log(JSON.stringify([...cursor]));
console.log(cursor.next().done);
1
[2,3]
true
After yielding one, spread consumes only two and three. Reusing the exhausted cursor does not restart the generator.
An iterable can create fresh cursors
Distinguish a repeatable collection from a single iterator carrying current position. A generator object is typically its own iterable iterator, so traversing it once consumes it. A generator function can be called again to create a fresh object. If an API promises repeated traversal, expose a factory or an iterable that creates a new cursor each time. Document whether input is consumed, particularly when multiple transformations share one source. Avoid testing only the first pass; a second pass can reveal accidental exhaustion that looks like missing data rather than a language exception.
Early exit has a cleanup contract
When a for...of loop exits early in the relevant way, iterator closing can call an iterator’s return method. A generator’s finally block can therefore run on break, which is useful for owned cleanup. Do not confuse this with automatically awaiting arbitrary asynchronous resource disposal: synchronous and asynchronous iteration have separate contracts. The example records closure deterministically, without opening a file or connection. For a real resource iterator, ensure setup and cleanup ownership are paired and test early exit, normal exhaustion, and exceptions. A consumer that manually calls next and abandons the cursor may need explicit closing.
const events = [];
function* ownedSequence() {
try { yield 1; yield 2; }
finally { events.push("closed"); }
}
for (const value of ownedSequence()) {
events.push(String(value));
break;
}
console.log(events.join(","));
1,closed
The second yield is never reached, but finally records cleanup. No external resource is involved.
Choose collection semantics deliberately
Set expresses membership without duplicate entries, while Map associates keys with values and preserves insertion order for iteration. Object keys can be used in Map with identity semantics; two equal-looking records remain different keys. A Set of objects does not deduplicate by an id property automatically. Decide the domain equality first, then choose a stable key such as a validated identifier when that is the real identity. Use bounded transformations and consider async iteration when values genuinely arrive asynchronously, rather than wrapping every ordinary array in asynchronous syntax that obscures a simple computation.
Common errors and fixes
The second traversal returns nothing
- Why it happens
- A consumed iterator is reused as a repeatable collection.
- How to fix it
- Create a fresh iterator or document single-pass consumption.
- Prevent it next time
- Test repeated traversal where the API promises it.
for...in yields unexpected keys
- Why it happens
- Property enumeration is confused with value iteration.
- How to fix it
- Use for...of or explicit own entries as appropriate.
- Prevent it next time
- State whether keys, entries, or values are required.
A lazy sequence exhausts memory
- Why it happens
- The consumer eagerly materializes unbounded output.
- How to fix it
- Bound input and consume incrementally.
- Prevent it next time
- Review consumer behavior alongside producer laziness.
Check your understanding
1. Why does a Set of two separate {id:1} objects have size two?
Show worked solution
Object membership uses identity rather than comparing the id fields. If the product needs uniqueness by id, validate ids and use a Map keyed by id or a deliberate deduplication rule. Decide which record wins on duplicate ids instead of silently depending on traversal accident.
2. A generator yields one and two, then returns three. What does [...generator()] contain?
Show worked solution
It contains [1, 2]. The return value accompanies a done result and is not a yielded element. Inspect next manually if the API intentionally exposes a final result, or yield three if three belongs to the consumed sequence.
Key takeaways
- Separate repeatable iterable data from a consumed cursor.
- Laziness, bounds, equality, and cleanup are explicit contracts.