07 - Effect.gen: Sequential Effects, Flat Syntax
A chain of flatMap calls produces correct, type-safe sequential code, and unreadable indentation. Effect.gen is sugar over that chain: a generator function whose yield* plays the role of await.
Every yielded effect’s success value becomes a local variable in the same scope; every failure short-circuits the generator.
What you’ll work through
index.test.ts contains five beats: a basic yield* chain, a conditional failure with return yield* Effect.fail, a short-circuit demonstration, a side-by-side gen-vs-flatMap equivalence, and an async generator that mixes Effect.sync with Effect.tryPromise. Most beats have one or two stubs.
By the end you should be able to write a sequential workflow as a flat generator and know when to reach for pipe instead.
Mental Model
yield* unwraps the success channel and binds it. The error channel is propagated to the outer effect, the line after a failing yield* does not run.
Effect.gen(function* () { const a = yield* stepA // success: a = A, continue // failure: generator stops, E propagates const b = yield* stepB // success: b = B, continue return a + b // ─► Effect<number, E_A | E_B, R_A | R_B>})The flat scope is the point: every bound value is visible to every later step. The flatMap form keeps each variable trapped inside its callback.
// flatMap: // gen:a.pipe(Effect.flatMap((a) => Effect.gen(function* () { b.pipe(Effect.flatMap((b) => const a = yield* a c.pipe(Effect.map((c) => const b = yield* b a + b + c const c = yield* c )) return a + b + c )) })))Unwrap with yield*
Creates an Effect from a generator. Each yield* consumes an effect and produces its success value.
The outer effect’s types are inferred from the return value and the union of every yielded effect.
import { Effect } from "effect"
// ┌─── Effect<number, never, never>// ▼const program = Effect.gen(function* () { const a = yield* Effect.succeed(1) const b = yield* Effect.succeed(2) const c = yield* Effect.succeed(3) return a + b + c})Details
The generator function does not take an adapter argument in Effect v4, yield* is applied directly to effects. The outer Effect’s E channel is the union of every yielded effect’s E; the R channel is the union of every yielded effect’s R.
The return value of the generator is the outer A.
A failure inside the generator behaves like an exception that the surrounding flatMap chain would have short-circuited on: subsequent statements do not execute, and the failure is the outer effect’s failure.
When to Use
For any sequential effect logic with more than one step, especially when later steps depend on earlier values. Reach for pipe(Effect.map, Effect.flatMap) for single-step transformations or when a single combinator (Effect.all, Effect.zip) is enough.
Your turn `yield*` each Effect so its success value lands in a plain local binding.
Variables in Scope
Every yield* result is a regular local binding.
// ┌─── Effect<{ user: User; posts: ReadonlyArray<Post> }, never, never>// ▼const program = Effect.gen(function* () { const user = yield* Effect.succeed({ id: "1", name: "Alice" }) const posts = yield* Effect.succeed(["post1", "post2"]) return { user, posts }})No closures, no callback nesting. Branches, loops, and try/finally work as in any other generator, the only special form is yield*.
Conditional Failure
A typed failure inside a generator is yield* Effect.fail(...). The generator stops at that point; the error widens the outer E.
import { Effect } from "effect"
// ┌─── Effect<number, "Division by zero", never>// ▼const divide = (a: number, b: number): Effect.Effect<number, "Division by zero"> => Effect.gen(function* () { if (b === 0) { return yield* Effect.fail("Division by zero" as const) } return a / b })The return yield* form is required because yield* Effect.fail(...) has type never, returning it satisfies the function signature without an unreachable branch.
Your turn `return yield* Effect.fail(...)` on the `b === 0` branch so the divide function rejects. The success branch should return `a / b` directly.
Short-Circuit
Code placed after a failing yield* does not execute. There is no compiler warning for dead code, so the assumption must be explicit.
const program = Effect.gen(function* () { yield* Effect.fail("boom") // never runs return "done"})For cleanup that must always run, use Effect.ensuring or Effect.acquireRelease (introduced in step 46).
Your turn `yield* Effect.fail(...)` in the middle of a generator and confirm the lines after it never execute.
gen and flatMap have the same semantics
Effect.gen is sugar over a flatMap chain. The two forms produce the same description; the generator just keeps every variable in one flat scope.
Your turn read both forms and confirm they return the same value.
Mixing Sync, Async, and Errors
A generator can yield any effect, sync, async, failing, depending on a service, and the inferred types fold every channel together.
import { Effect, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{ url: string }> {}
// ┌─── Effect<{ user: unknown; posts: unknown }, FetchError, never>// ▼const program = Effect.gen(function* () { const config = yield* Effect.sync(() => ({ baseUrl: "https://api.example.com" }))
const user = yield* Effect.tryPromise({ try: () => fetch(`${config.baseUrl}/users/1`).then((r) => r.json()), catch: () => new FetchError({ url: `${config.baseUrl}/users/1` }), })
const posts = yield* Effect.tryPromise({ try: () => fetch(`${config.baseUrl}/posts?userId=${(user as { id: string }).id}`).then((r) => r.json()), catch: () => new FetchError({ url: `${config.baseUrl}/posts` }), })
return { user, posts }})Your turn `yield*` an `Effect.tryPromise` so the resolved value is bound locally and a rejection lands in E.
Rule: gen for sequencing, pipe for instrumentation
Effect.gen is the canonical way to sequence dependent steps. Reach for it whenever one step needs the result of the previous.
pipe is the canonical way to decorate a workflow — retry, timeout, log annotations, span attachment — without changing what the workflow does.
// ✅ gen — each step depends on the previousconst order = Effect.gen(function* () { const user = yield* fetchUser(id) const cart = yield* loadCart(user.id) const total = yield* priceCart(cart) return { user, cart, total }})
// ✅ pipe — apply cross-cutting concerns at the edgeconst robust = order.pipe( Effect.retry(Schedule.recurs(3)), Effect.timeout("5 seconds"), Effect.annotateLogs({ orderId: id }),)If you find yourself chaining flatMap more than two or three times inside a pipe, switch to gen. If you’re attaching retry/timeout/logging to a workflow, switch to pipe.
The split keeps both forms readable.
Rules of thumb
yield*for sequencing dependent steps;pipefor decoration. When a step needs the previous result, usegen. When you’re wrapping a workflow in retry/timeout/log, usepipe.- Never run inside a
gen.Effect.runPromiseinside a generator is almost always a bug —yield*the inner Effect instead. genis where local variables live. It’s the natural home for shared intermediate values, accumulators, and audit arrays.
Common Pitfalls
1, yield instead of yield*. yield produces the effect value itself, not its success channel. The local binding becomes Effect<A, E, R> instead of A.
Always yield*.
2, Forgetting that failure ends the generator. Lines after a failing yield* are dead code. Use Effect.ensuring or Effect.acquireRelease for guaranteed cleanup.
3, runSync on an async generator. If any yielded effect is async, Effect.runSync throws. Use Effect.runPromise.
4, Trying to await inside the generator. await is for async functions; Effect.gen uses yield*. Wrap promises with Effect.tryPromise and yield them.
derived value. - [ ] Complete `conditional-fail`: `return yield* Effect.fail(...)` for the divide-by-zero case. - [ ] Read `short-circuit` and confirm code after a failing `yield*` never runs. - [ ] Compare the `gen-vs-flatmap` pair and note that both forms produce the same description. - [ ] Complete `async-yield`: `yield*` an `Effect.tryPromise` so the resolved value binds locally and rejection lands in `E`. - [ ] Note the "gen for sequencing, pipe for instrumentation" rule — it reappears in nearly every step that follows.
See Also
- Previous,
06-pipe-and-flow: theflatMapformgendesugars to. - Next,
08-sync-vs-async-constructors: choosing the right constructor for whatyield*consumes. - Related,
09-error-handling:catchTag,catch,mapErroron generator effects. - Related,
13-async-operations:Effect.promiseandEffect.tryPromise.
Run the tests: pnpm test 07-effect-gen
The full test suite
The index.test.ts for this lesson, verbatim from steps/07-effect-gen/.
import { it, expect } from "vite-plus/test"import { Effect, Exit } from "effect"
// =============================================================================// 06 - Effect.gen: Sequential Effects, Flat Syntax// =============================================================================//// yield* unwraps the success channel; failures short-circuit the generator.// The outer Effect's E is the union of every yielded effect's E.
// @beat// id: yield-star// shape: multi-todo// title: Unwrap success with yield*// teaches: yield* applied to an Effect binds its success value as a local variable in the same scope.// hint: prefix each Effect with yield* to get its A value into a local bindingit("unwraps the success value", () => { // ┌─── Effect<number, never, never> // ▼ const program = Effect.gen(function* () { const a = /* @stub */ yield* Effect.succeed(1) /* @end */ const b = /* @stub */ yield* Effect.succeed(2) /* @end */ return a + b })
expect(Effect.runSync(program)).toBe(3)})
it("keeps every bound value in the same scope", () => { const program = Effect.gen(function* () { const user = /* @stub */ yield* Effect.succeed({ id: "1", name: "Alice" }) /* @end */ const posts = /* @stub */ yield* Effect.succeed(["post1", "post2"]) /* @end */ return { user, posts } })
const result = Effect.runSync(program) expect(result.user.name).toBe("Alice") expect(result.posts.length).toBe(2)})// @end-beat
// @beat// id: short-circuit// shape: multi-todo// title: Failure short-circuits the generator// teaches: A failing yield* stops the generator; every later statement is dead code and the failure propagates.// hint: yield* Effect.fail to short-circuit — the lines after it never runit("stops at the first failing yield*", () => { let reached = false const program = Effect.gen(function* () { const a = yield* Effect.succeed(1) /* @stub */ yield* Effect.fail("boom") /* @end */ reached = true return a })
expect(Exit.isFailure(Effect.runSyncExit(program))).toBe(true) expect(reached).toBe(false)})
it("skips every statement after the failure", () => { const log: string[] = []
const program = Effect.gen(function* () { log.push("step 1 started") yield* Effect.succeed("ok") log.push("step 1 done")
log.push("step 2 will fail") /* @stub */ yield* Effect.fail("error") /* @end */
log.push("step 3 — never runs") log.push("step 4 — never runs") return "never" })
Effect.runSyncExit(program) expect(log).toEqual(["step 1 started", "step 1 done", "step 2 will fail"])})// @end-beat
// @beat// id: conditional-fail// shape: one-todo// title: Fail conditionally inside a generator// teaches: `return yield* Effect.fail(...)` lets a generator reject on a branch while preserving the inferred E type.// hint: yield* Effect.fail has type never — return it to satisfy the function signatureit("conditionally fails on a branch", () => { // ┌─── Effect<number, "Division by zero", never> // ▼ const divide = (a: number, b: number): Effect.Effect<number, "Division by zero"> => Effect.gen(function* () { if (b === 0) { return /* @stub */ yield* Effect.fail("Division by zero" as const) /* @end */ } return a / b })
expect(Effect.runSync(divide(10, 2))).toBe(5) expect(Exit.isFailure(Effect.runSyncExit(divide(10, 0)))).toBe(true)})// @end-beat
// @beat// id: gen-vs-flatmap// shape: read-run// title: gen and flatMap have the same semantics// teaches: Effect.gen compiles to the same flatMap chain — the generator is sugar for nested callbacks.// hint: compare both forms and notice the inferred type and result are identicalit("nested flatMap produces the same result", () => { const program = Effect.succeed(1).pipe( Effect.flatMap((a) => Effect.succeed(2).pipe( Effect.flatMap((b) => Effect.succeed(3).pipe(Effect.map((c) => a + b + c))), ), ), )
expect(Effect.runSync(program)).toBe(6)})
it("Effect.gen keeps the same logic flat", () => { const program = Effect.gen(function* () { const a = yield* Effect.succeed(1) const b = yield* Effect.succeed(2) const c = yield* Effect.succeed(3) return a + b + c })
expect(Effect.runSync(program)).toBe(6)})// @end-beat
// @beat// id: async-yield// shape: multi-todo// title: Yield async effects inside a generator// teaches: Effect.tryPromise lifts a Promise into an Effect; yielding it inside Effect.gen unwraps the resolved value (or routes the catch to E).// hint: yield* the Effect.tryPromise result the same way as any other effectit("yields a tryPromise result on success", async () => { const fetchUser = async (id: string) => { if (id === "0") throw new Error("User not found") return { id, name: "Alice" } }
const program = Effect.gen(function* () { const user = /* @stub */ yield* Effect.tryPromise({ try: () => fetchUser("123"), catch: (cause) => new Error(`Fetch failed: ${String(cause)}`), }) /* @end */ return user.name })
await expect(Effect.runPromise(program)).resolves.toBe("Alice")})
it("places the catch result in E when the promise rejects", async () => { const fetchUser = async (id: string) => { if (id === "0") throw new Error("User not found") return { id, name: "Alice" } }
const program = Effect.gen(function* () { const user = /* @stub */ yield* Effect.tryPromise({ try: () => fetchUser("0"), catch: (cause) => new Error(`Fetch failed: ${String(cause)}`), }) /* @end */ return user.name })
const exit = await Effect.runPromiseExit(program) expect(Exit.isFailure(exit)).toBe(true)})// @end-beat