Skip to content

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.

There are two distinct protocols that constantly get conflated:

// The Iterator protocol — a cursor over values
interface 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.

Most developers know next() returns { value, done }. The subtleties:

  1. Values flow in both directions. next(arg) makes arg the value of the currently suspended yield expression. 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 your const x = yield* ... lines.

  2. throw() and return() complete the generator from outside. iterator.throw(err) makes the suspended yield expression throw err (your try/catch inside the generator sees it); iterator.return(v) forces done: true with value v, running finally blocks 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 }
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 out
  • R — the return type when done
  • N — the type the caller may pass into next()

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* 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() types a as number.
  • Exceptions thrown by the inner iterator propagate out.
  • Crucially: if the outer generator receives return() while suspended inside a yield*, the inner iterator also gets return() — unwinding propagates through delegation chains, running inner finally blocks.

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.

  • Generators have engine-level fast paths (V8 optimizes them well), but each yield is still a real suspension — an object allocation for the result wrapper plus a state-machine transition.
  • Effect’s Effect.gen doesn’t use for await or helper libraries; it drives iterator.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 nested gen bodies do not risk stack overflow the way naive promise-recursion can.
  1. next(value) injects; throw(err) raises; return(v) unwinds — the driver controls the generator’s reality.
  2. yield* delegates fully: values, arguments, exceptions, and early completion all pass through, and its expression value is the delegate’s return value.
  3. A generator body is a blueprint; the iterator instance is the single execution. Re-runnable programs re-invoke the body.
  4. Interpreters give yielded values meaning. Effect’s meaning: “suspend this fiber until this described computation produces a result.”