ution
Implementing a robust custom iteration protocol requires more than returning a next() function. It demands strict adherence to the ECMAScript contract, deliberate state isolation, and awareness of lifecycle hooks. Below is a step-by-step breakdown of production-ready implementations.
Step 1: Understand the Full Protocol Contract
The ECMAScript specification defines an iterator as an object with a next() method that returns { value, done }. However, production-grade iterators should also implement return() and throw() to handle early termination (break, return, exceptions) and resource cleanup.
Step 2: Implement an Eager Iterator with State Isolation
Eager iterators are appropriate when the dataset is bounded, predictable, and requires fast sequential access. The key is isolating iteration state from the source object to prevent cross-iteration contamination.
class TaskQueue {
private tasks: string[];
private version: number = 0;
constructor(initialTasks: string[]) {
this.tasks = [...initialTasks];
}
add(task: string): void {
this.tasks.push(task);
this.version++;
}
[Symbol.iterator](): Iterator<string> {
const snapshot = [...this.tasks];
const initialVersion = this.version;
let cursor = 0;
return {
next(): IteratorResult<string> {
if (cursor < snapshot.length) {
return { value: snapshot[cursor++], done: false };
}
return { value: undefined, done: true };
},
return(): IteratorResult<string> {
cursor = snapshot.length;
return { value: undefined, done: true };
}
};
}
}
Architecture Rationale:
- We snapshot the array on iterator creation to prevent mutation drift during traversal.
- A
version counter tracks modifications, enabling future validation logic.
- The
return() method ensures clean early termination, preventing memory leaks in long-running loops.
Step 3: Implement a Lazy Generator for Unbounded Streams
When dealing with event streams, paginated APIs, or infinite sequences, eager allocation is impossible. Generators provide automatic state management and pause/resume semantics.
class LogStream {
private buffer: string[] = [];
private isClosed = false;
push(entry: string): void {
if (!this.isClosed) this.buffer.push(entry);
}
close(): void {
this.isClosed = true;
}
*[Symbol.iterator](): Generator<string, void, unknown> {
let index = 0;
while (!this.isClosed || index < this.buffer.length) {
if (index < this.buffer.length) {
yield this.buffer[index++];
} else {
// In a real system, this would await an event emitter or queue
yield new Promise(resolve => setTimeout(resolve, 100)).then(() => '');
}
}
}
}
Architecture Rationale:
- The
* syntax automatically handles next(), return(), and throw() wiring.
- Lazy evaluation ensures memory scales with active consumption, not total production.
- The loop condition gracefully handles stream closure without throwing.
Step 4: Handle Graph Traversal with Iterative State
Recursive iterators risk stack overflow on deep structures. An iterative approach using an explicit stack is safer and more predictable.
class NetworkRouter {
public id: string;
public connections: NetworkRouter[] = [];
constructor(id: string) {
this.id = id;
}
link(node: NetworkRouter): void {
this.connections.push(node);
}
*[Symbol.iterator](): Generator<NetworkRouter, void, unknown> {
const visited = new Set<NetworkRouter>();
const stack: NetworkRouter[] = [this];
while (stack.length > 0) {
const current = stack.pop()!;
if (visited.has(current)) continue;
visited.add(current);
yield current;
for (const neighbor of current.connections) {
stack.push(neighbor);
}
}
}
}
Architecture Rationale:
- Depth-first traversal using an explicit stack avoids call stack limits.
- A
visited set prevents infinite loops in cyclic graphs.
- Generator syntax keeps the traversal logic declarative while maintaining O(V+E) time complexity.
Pitfall Guide
1. State Leakage Across Iterations
Explanation: Returning the same iterator object from [Symbol.iterator]() causes subsequent loops to resume from the previous termination point instead of starting fresh.
Fix: Always return a new iterator instance on every call. Use closures or fresh class instances to isolate cursor/index state.
2. Mutation Blindness
Explanation: Modifying the underlying collection during iteration leads to skipped items, duplicates, or out-of-bounds errors.
Fix: Snapshot data on iterator creation, or implement a version counter that throws a ConcurrentModificationError if drift is detected.
3. Ignoring return() and throw()
Explanation: for...of loops with break, return, or exceptions expect the iterator to clean up resources. Omitting these methods causes memory leaks or locked file handles.
Fix: Explicitly implement return() to reset state and release resources. Generators handle this automatically, but manual iterators require it.
4. Premature done: true or Infinite Loops
Explanation: Off-by-one errors in boundary checks or missing termination conditions cause silent early exits or infinite loops that freeze the event loop.
Fix: Write invariant tests that verify done transitions exactly once. Use strict cursor < length checks and avoid mutating the length property during iteration.
5. Over-Eager Evaluation
Explanation: Pre-computing all values in memory before yielding defeats the purpose of custom iteration and causes OOM crashes on large datasets.
Fix: Switch to generator functions or chunked yielding. Only compute values when next() is called.
6. Type Coercion Traps
Explanation: Returning { value: undefined, done: true } vs { done: true } (missing value) breaks destructuring and spread syntax in strict environments.
Fix: Always return both value and done keys. Use IteratorResult<T> typing to enforce contract compliance.
7. Debugging Blind Spots
Explanation: Silent failures in for...of loops make it difficult to trace where iteration breaks or yields unexpected values.
Fix: Wrap iterator logic in try/catch blocks during development, log invariant violations, and attach Symbol.toStringTag for readable console output.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Fixed-size configuration arrays | Eager manual iterator | Predictable bounds, minimal overhead | Low |
| Real-time event streams | Lazy generator | Unbounded data, pause/resume semantics | Medium |
| Graph/network traversal | Iterative generator with visited set | Prevents stack overflow, handles cycles | Medium |
| High-frequency UI rendering | Snapshot-based eager iterator | Deterministic order, avoids mutation drift | Low-Medium |
| Memory-constrained environments | Chunked lazy generator | O(1) memory footprint, backpressure support | High (initial dev) / Low (runtime) |
Configuration Template
// Base mixin for production-ready iterables
export function createIterable<T>(
generate: () => Iterator<T>
): { [Symbol.iterator]: () => Iterator<T> } {
return {
[Symbol.iterator]() {
const iterator = generate();
// Enforce protocol contract
if (typeof iterator.next !== 'function') {
throw new TypeError('Iterator must implement next()');
}
return iterator;
}
};
}
// Usage example
const range = createIterable<number>(() => {
let current = 0;
const max = 10;
return {
next(): IteratorResult<number> {
if (current < max) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
return(): IteratorResult<number> {
current = max;
return { value: undefined, done: true };
}
};
});
for (const n of range) {
console.log(n); // 0..9
}
Quick Start Guide
- Define your data source: Identify whether your collection is bounded (eager) or unbounded/streaming (lazy).
- Choose implementation style: Use manual
next() for simple arrays, or * generators for complex state, async flows, or cleanup requirements.
- Implement
[Symbol.iterator](): Return a fresh iterator object. Ensure next() returns { value, done } and add return() for early termination.
- Test boundaries: Run
for...of, spread [...iter], and break scenarios. Verify memory stays flat for lazy implementations.
- Deploy with invariant guards: Wrap production iterators in development-mode checks that validate protocol compliance before shipping.