Iterators & Generators
The JavaScript iteration protocol at full depth — iterators, iterables, typed generators, yield* delegation, early exit, and why coroutines are a library feature. The foundation Effect builds on.
You probably know generators well enough to use them. This chapter closes the
remaining gaps, because Effect v4’s entire authoring experience — yield*,
Effect.gen, Effect.fn — is a direct consumer of these mechanics, and the
subtleties (return values of next(), delegation semantics, the three type
parameters of Generator, what happens on early termination) are exactly the
things people half-know.
The two protocols
Section titled “The two protocols”There are two distinct protocols that constantly get conflated:
// The Iterator protocol — a cursor over valuesinterface Iterator<T, TReturn = any, TNext = undefined> { next(...args: [] | [TNext]): IteratorResult<T, TReturn>}
type IteratorResult<T, TReturn = any> = | { done: false; value: T } | { done: true; value: TReturn }
// The Iterable protocol — "I can hand out a fresh iterator"interface Iterable<T> { [Symbol.iterator](): Iterator<T>}
// IterableIterator — both at once (what generators produce)interface IterableIterator<T> extends Iterator<T>, Iterable<T> {}An iterator is a one-shot cursor: call next() repeatedly until done.
An iterable is a factory: every [Symbol.iterator]()
call yields a fresh cursor, which is why arrays can be iterated many times.
Generators return objects that are both — each call to the generator function
creates an independent cursor.
The full shape of next()
Section titled “The full shape of next()”Most developers know next() returns { value, done }. The subtleties:
-
Values flow in both directions.
next(arg)makesargthe value of the currently suspendedyieldexpression. Generators are the only JS construct where the callee injects values into the caller’s expression position — this bidirectional channel is precisely what Effect exploits to feed success values back into yourconst x = yield* ...lines. -
throw()andreturn()complete the generator from outside.iterator.throw(err)makes the suspendedyieldexpression throwerr(yourtry/catchinside the generator sees it);iterator.return(v)forcesdone: truewith valuev, runningfinallyblocks along the way. The Effect runtime uses both paths to inject failures and to unwind generators when a fiber is interrupted.
function* demo() { try { const received = yield 1 // suspends here console.log("got:", received) yield 2 } finally { console.log("cleanup ran") // runs on return()/throw()/GC-completion }}
const it = demo()it.next() // { value: 1, done: false }it.next("hello") // logs "got: hello" → { value: 2, done: false }it.return("bye") // logs "cleanup ran" → { value: "bye", done: true }Typed generators: three parameters
Section titled “Typed generators: three parameters”interface Generator<Y = unknown, R = unknown, N = unknown> extends Iterator<Y, R, N> { next(...args: [] | [N]): IteratorResult<Y, R>}Y— the type of values yielded outR— the return type when doneN— the type the caller may pass intonext()
TypeScript infers all three from the body:
function* counter(): Generator<number, string, void> { let i = 0 while (i < 3) { const _ignored = yield i++ // yield returns void here } return "done"}When you use yield x (not yield*), TS types the yield-expression’s value
as N. When you write yield* expr, TS uses the delegated iterable’s own
N — this is how yield* someEffect gets its A type into your variable.
yield* — delegation, not syntax sugar
Section titled “yield* — delegation, not syntax sugar”yield* delegates to another iterable/iterator:
- It drives the inner iterator to completion, forwarding every
next()argument into it and every yielded value out of it. - Its expression value is the inner iterator’s return value — so
const a = yield* genReturningNumber()typesaasnumber. - Exceptions thrown by the inner iterator propagate out.
- Crucially: if the outer generator receives
return()while suspended inside ayield*, the inner iterator also getsreturn()— unwinding propagates through delegation chains, running innerfinallyblocks.
That last point is load-bearing for cancellation: when Effect interrupts a
fiber running your Effect.gen body, it calls return() on the current
iterator, which cascades through any active yield* — though for Effects the
runtime handles cleanup through finalizers rather than generator finally.
function* inner(): Generator<number, number, void> { yield 1 yield 2 return 42}
function* outer(): Generator<number, number, void> { const result = yield* inner() // result === 42 return result + 1}Note what yield* did not do: it did not flatten laziness away magically.
Delegation is synchronous control-flow plumbing — whoever drives the outer
iterator is unknowingly driving the inner one too. Hold that thought.
Generators as interpreters: building async/await by hand
Section titled “Generators as interpreters: building async/await by hand”Here’s the trick that unlocks all of Effect: a generator is a resumable
function, and whoever holds the iterator decides what yielded values mean.
async/await is exactly this pattern baked into engines: transpile-era code
(regenerator, co.js) interpreted generators before we had native support.
Let’s write a coroutine runner for thunks:
type Thunk<A> = () => Promise<A>
function run<A>(genFn: () => Generator<Thunk<any>, A, any>): Promise<A> { return new Promise((resolve, reject) => { const it = genFn() function step(next: IteratorResult<Thunk<any>, A>): void { if (next.done) return resolve(next.value) // Interpret the yielded thunk: run it, feed result back in next.value().then( (value) => step(it.next(value)), (error) => step(it.throw!(error)) ) } step(it.next()) })}
const program = function* () { const user = yield () => fetchUser(1) // "await" const posts = yield () => fetchPosts(user.id) return [user, posts] as const}
run(program).then(console.log)Twenty lines, and we have: imperative-looking async code, errors flowing
through normal try/catch, and a program that is a value (the generator
factory) separate from its execution (the runner). Everything specific to
promises lives in one function — swap the interpreter and the same body means
something else.
That is the entire conceptual jump to Effect:
| toy runner | Effect runtime |
|---|---|
Thunk<A> yielded |
Effect<A, E, R> descriptions yielded |
feeds resolved promise value back via next(value) |
feeds success A via next(a) |
rejects → it.throw(error) |
fails → resumes the generator so the yield* raises the typed error |
| no cancellation | interrupts → unwinds the iterator, runs finalizers |
| no context | threads a Context through, satisfying R |
| no scheduling | cooperative scheduler, fiber budget, keep-alive |
The difference is that Effect’s interpreter is a production-grade fiber runtime — and because yielded values are inert descriptions, the same body can be executed eagerly, inspected, retried, timed, traced, or run under a virtual clock without changing a line.
Performance notes worth knowing
Section titled “Performance notes worth knowing”- Generators have engine-level fast paths (V8 optimizes them well), but each
yieldis still a real suspension — an object allocation for the result wrapper plus a state-machine transition. - Effect’s
Effect.gendoesn’t usefor awaitor helper libraries; it drivesiterator.next(value)directly in a loop (see next chapter), which keeps the overhead to the minimum the protocol allows. yield*on an Effect is not a nested run — there is no recursion, no stack growth. Delegation is flattened at the iterator level; the runtime sees one linear sequence of instructions. Deeply nestedgenbodies do not risk stack overflow the way naive promise-recursion can.
What to remember
Section titled “What to remember”next(value)injects;throw(err)raises;return(v)unwinds — the driver controls the generator’s reality.yield*delegates fully: values, arguments, exceptions, and early completion all pass through, and its expression value is the delegate’s return value.- A generator body is a blueprint; the iterator instance is the single execution. Re-runnable programs re-invoke the body.
- Interpreters give
yielded values meaning. Effect’s meaning: “suspend this fiber until this described computation produces a result.”