06 - Pipe and Flow: Composing Effects
Composition is how a small set of constructors becomes a real program. pipe threads a value through a sequence of functions left to right.
flow builds the sequence without a value, so it can be reused. Effect.map and Effect.flatMap are the operators that flow through the pipeline.
What you’ll work through
index.test.ts covers four operators (pipe, Effect.map, Effect.flatMap, flow) and one cross-cutting behaviour (short-circuit on failure). Most beats have one or two stubbed lines — you’ll fill in the pipeline stage that matches the type comment.
By the end you should be able to choose between pipe and flow, and between map and flatMap, without thinking about it.
Mental Model
pipe and flow differ only in whether the value is in hand yet.
pipe, value first, apply now pipe(value, f, g, h) ≡ h(g(f(value)))
flow, function first, apply later flow(f, g, h) ≡ (x) => h(g(f(x)))Pipelines short-circuit on failure: once an Effect enters the error channel, subsequent map / flatMap steps are skipped and the error propagates to the runner.
Effect.succeed(5) │ Effect.map((n) => n * 2) // 10 │ Effect.flatMap(() => fail("x")) // ──► E channel │ Effect.map((n) => n + 1) // skipped ▼Failure("x")Thread a value through pipe
Threads a value through a sequence of unary functions, left to right.
import { pipe, Effect } from "effect"
// ┌─── Effect<string, never, never>// ▼const program = pipe( Effect.succeed(5), Effect.map((n) => n * 2), Effect.map((n) => n + 3), Effect.map((n) => n.toString()),)Details
pipe is also a method on every Effect. effect.pipe(f1, f2) is identical to pipe(effect, f1, f2).
The method form reads better when the starting value is already an Effect; the standalone form reads better when the first argument is a plain value or when several non-Effect values are involved.
When to Use
Whenever a single value is being transformed through more than one step. Below two steps, nested calls are fine; above two, pipe wins.
Notice. Each operator is unary — it takes exactly one value, produces exactly one value. That’s why
pipecan thread them: the output of stage N becomes the input of stage N+1.
Your turn fill in the three pipeline stages and the standalone `pipe`, then thread a plain string through trim, uppercase, split. The string test proves `pipe` works on any value, not just `Effect`.
Effect.map
Transforms the success value with a pure function. Leaves the error channel untouched.
// ┌─── Effect<number, never, never>// ▼const doubled = Effect.succeed(5).pipe(Effect.map((n) => n * 2))Details
map only touches the success channel. If the upstream Effect is in E, the callback never runs and the failure flows through untouched — same short-circuit rule you’ll exercise two beats down.
When to Use
When the next step is a pure transformation that cannot itself fail. If the callback would need to do Effect.fail, Effect.sleep, fetch a service, or return another Effect, reach for flatMap instead.
@see Effect.flatMap when the next step returns an Effect.
Your turn wrap the pure doubling transformation in `Effect.map((n) => n * 2)` so the pipeline produces `10` for an input of `5`.
Effect.flatMap
Chains an effectful step. The callback returns a new Effect; its error type unions into the outer E.
// ┌─── Effect<string, "too small", never>// ▼const program = Effect.succeed(5).pipe( Effect.flatMap((n) => Effect.succeed(n * 2)), Effect.flatMap((n) => n > 5 ? Effect.succeed(`big: ${n}`) : Effect.fail("too small" as const), ),)Details
A pipeline of flatMap calls is the underlying form of every sequential Effect program. Effect.gen (step 07) compiles to exactly this shape, the generator is sugar for the nested callbacks.
When to Use
When the next step is itself an Effect. Returning a plain value from flatMap is a type error; use map for pure transformations.
Your turn chain two `flatMap` callbacks that each return another `Effect`, then add a predicate branch that succeeds or fails depending on the value. Notice the inferred error type widens to include the failure literal.
Short-circuit on failure
Once an effect enters the error channel, every later map / flatMap is skipped and the failure propagates straight to the runner. This is the same control flow as throw — except the failure is a typed value in E, visible in the signature, and recoverable by catchTag (step 09).
const log: string[] = []
const program = Effect.succeed(5).pipe( Effect.map((n) => { log.push("step1"); return n * 2 }), // runs Effect.flatMap(() => { log.push("step2"); return Effect.fail("boom") }), Effect.map((n) => { log.push("step3"); return n + 3 }), // skipped)
Effect.runSyncExit(program)// log === ["step1", "step2"] ── "step3" never executed.Rule of thumb. Every operator after a failed step is dead code until someone calls a recovery operator. The error doesn’t “throw” — it just rides the pipeline and bypasses the success branches.
Your turn predict which labels land in `log` before the failure fires and replace the stubbed array. Hint: the failing `flatMap` itself still runs and pushes its label *before* signalling failure; everything *after* is skipped.
flow
Composes functions into a new function. Same semantics as pipe, minus the initial value.
import { flow, Effect } from "effect"
const normalize = flow( Effect.map((s: string) => s.trim()), Effect.map((s) => s.toLowerCase()), Effect.map((s) => s.replace(/\s+/g, "-")),)
// ┌─── Effect<string, never, never>// ▼const program = normalize(Effect.succeed(" Hello World "))When to Use
When the same pipeline applies to many inputs. Reach for flow whenever the temptation arises to write const f = (x) => pipe(x, ...).
Your turn assemble a `flow` of plain functions and a `flow` of `Effect.map` steps that both produce reusable functions.
A Composed Example
A short pipeline that mixes flatMap (effectful) and map (pure), and short-circuits on failure.
import { Effect } from "effect"
// ┌─── Effect<string, "too small", never>// ▼const program = Effect.succeed(5).pipe( Effect.map((n) => n * 2), Effect.flatMap((n) => n > 5 ? Effect.succeed(n) : Effect.fail("too small" as const), ), Effect.map((n) => `value=${n}`),)
Effect.runSync(program) // "value=10"If the predicate had returned Effect.fail, the trailing map would not run; the failure propagates to the runner unchanged.
Rule: pipe for instrumentation, gen for sequencing
The next step introduces Effect.gen, which is the natural choice when each step depends on the previous. pipe and flow are best reserved for instrumentation — operations that wrap a whole workflow without changing its shape:
// ✅ pipe — wrapping a workflow with retry / timeout / loggingprogram.pipe( Effect.retry(Schedule.recurs(3)), Effect.timeout("5 seconds"), Effect.annotateLogs({ requestId }),)
// ✅ gen — sequencing dependent steps inside a workflowconst program = Effect.gen(function* () { const user = yield* fetchUser(id) const orders = yield* fetchOrders(user.id) return { user, orders }})Inside a workflow, prefer gen. At the edge of a workflow, reach for pipe.
The two compose naturally — gen builds the program, pipe decorates it.
Common Pitfalls
1, Returning a plain value from flatMap. flatMap expects A => Effect<B, E2, R2>. A plain B is a type error.
Use map for pure transformations.
2, Confusing pipe and flow. pipe(value, f, g) runs now. flow(f, g) returns a function that runs later.
3, Mutating in map. map callbacks should be pure. Side effects belong in Effect.sync so they participate in the description.
4, Method .pipe vs standalone pipe. They are equivalent. Pick the form that reads better at the call site.
which contain stubs. - [ ] Work through the beats in order: `pipe-basics` → `effect-map` → `effect-flatmap` → `short-circuit` → `flow`. Each one has a type comment (`// ┌─── Effect<...>`) showing the expected result type. - [ ] After completing `effect-flatmap`, intentionally return a plain value from one of the callbacks and read the type error. Restore the `Effect.succeed` wrap to see it pass. - [ ] Note the placement of the `gen` vs `pipe` rule below — step 07 picks this up immediately.
See Also
- Previous,
05-effect-basics: the constructors that produce the effects you compose. - Next,
04-error-types: typed errors and the_tagdiscriminant. - Later,
07-effect-gen: sugar overflatMapchains. - Later,
09-error-handling: composing recovery into the pipeline.
Run the tests: pnpm test 06-pipe-and-flow
The full test suite
The index.test.ts for this lesson, verbatim from steps/06-pipe-and-flow/.
import { it, expect } from "vite-plus/test"import { Effect, Exit, pipe, flow } from "effect"
// =============================================================================// 04 - Pipe and Flow: Composing Effects// =============================================================================//// pipe(value, f, g) threads a value through functions left-to-right.// flow(f, g) returns a new function — pipe without the value.// Effect.map transforms success with a pure function.// Effect.flatMap chains an effectful step (returns a new Effect).
// @beat// id: pipe-basics// shape: multi-todo// title: Thread a value through pipe// teaches: pipe applies a sequence of unary functions left to right and works on any value, not just Effects.// hint: pass the value first, then the transformations in the order you want them appliedit("threads a value through a sequence of functions", () => { // ┌─── Effect<string, never, never> // ▼ const program = Effect.succeed(5).pipe( /* @stub */ Effect.map((n) => n * 2) /* @end */, /* @stub */ Effect.map((n) => n + 3) /* @end */, /* @stub */ Effect.map((n) => n.toString()) /* @end */, )
expect(Effect.runSync(program)).toBe("13")})
it("works as a standalone function", () => { const program = /* @stub */ pipe( Effect.succeed(10), Effect.map((n) => n * 2), Effect.map((n) => n + 1), ) /* @end */
expect(Effect.runSync(program)).toBe(21)})
it("composes plain values and plain functions", () => { const result = pipe( " hello world ", /* @stub */ (s) => s.trim() /* @end */, /* @stub */ (s) => s.toUpperCase() /* @end */, /* @stub */ (s) => s.split(" ") /* @end */, )
expect(result).toEqual(["HELLO", "WORLD"])})// @end-beat
// @beat// id: effect-map// shape: one-todo// title: Transform success with Effect.map// teaches: Effect.map applies a pure function to the success channel and leaves the error channel untouched.// hint: wrap a pure transformation, not one that returns another Effectit("transforms the success value with a pure function", () => { const program = Effect.succeed(5).pipe(/* @stub */ Effect.map((n) => n * 2) /* @end */) expect(Effect.runSync(program)).toBe(10)})// @end-beat
// @beat// id: effect-flatmap// shape: multi-todo// title: Chain effectful steps with flatMap// teaches: Effect.flatMap chains an effect-returning callback; its error type unions into the outer E.// hint: the callback must return an Effect — Effect.succeed or Effect.fail, never a plain valueit("chains an effectful step", () => { const program = Effect.succeed(5).pipe( /* @stub */ Effect.flatMap((n) => Effect.succeed(n * 2)) /* @end */, /* @stub */ Effect.flatMap((n) => Effect.succeed(n + 1)) /* @end */, )
expect(Effect.runSync(program)).toBe(11)})
it("unions the inner error into the outer E", () => { // ┌─── Effect<string, "too small", never> // ▼ const program = Effect.succeed(5).pipe( Effect.flatMap((n) => /* @stub */ n > 5 ? Effect.succeed(`big: ${n}`) : Effect.fail("too small" as const) /* @end */, ), )
const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true)})// @end-beat
// @beat// id: short-circuit// shape: one-todo// title: Failures short-circuit the pipeline// teaches: Once an Effect enters the error channel, subsequent map/flatMap steps are skipped and the failure propagates.// hint: which steps logged before the failure fired? everything after is skippedit("short-circuits at the first failure", () => { const log: string[] = []
const program = Effect.succeed(5).pipe( Effect.map((n) => { log.push("step1") return n * 2 }), Effect.flatMap(() => { log.push("step2") return Effect.fail("boom" as const) }), Effect.map((n) => { log.push("step3") return n + 3 }), )
const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true) // Predict which step labels made it into the log before the failure short-circuited the rest. expect(log).toEqual(/* @stub */ ["step1", "step2"] /* @end */)})
it("short-circuits on flatMap failure", () => { const program = Effect.succeed(5).pipe( Effect.flatMap(() => Effect.fail("first")), Effect.flatMap(() => Effect.succeed("never")), )
expect(Exit.isFailure(Effect.runSyncExit(program))).toBe(true)})// @end-beat
// @beat// id: flow// shape: multi-todo// title: Build reusable pipelines with flow// teaches: flow composes functions without a starting value, producing a reusable function.// hint: flow is pipe without the value — reach for it when the same pipeline applies to many inputsit("composes plain functions into a new function", () => { const process = /* @stub */ flow( (n: number) => n * 2, (n: number) => n + 1, (n: number) => `Result: ${n}`, ) /* @end */
expect(process(5)).toBe("Result: 11") expect(process(10)).toBe("Result: 21")})
it("builds reusable Effect pipelines", () => { const normalize = /* @stub */ flow( Effect.map((s: string) => s.trim()), Effect.map((s) => s.toLowerCase()), Effect.map((s) => s.replace(/\s+/g, "-")), ) /* @end */
expect(Effect.runSync(normalize(Effect.succeed(" Hello World ")))).toBe("hello-world") expect(Effect.runSync(normalize(Effect.succeed(" Effect TS ")))).toBe("effect-ts")})// @end-beat