Skip to content

Building Pipelines

Pipelines compose effects as a sequence of transformations, read left to right. Where Effect.gen shines for multi-step imperative logic, pipe shines for short transformations and for attaching cross-cutting behaviour - logging, spans, retries - to an existing effect. The two styles interoperate freely; you will use both.

import { Effect, pipe } from "effect"
// A function to apply a discount, which may fail.
const applyDiscount = (total: number, rate: number) =>
rate === 0
? Effect.fail("Discount rate cannot be zero" as const)
: Effect.succeed(total - (total * rate) / 100)
const fetchAmount = Effect.succeed(100)
const program = pipe(
fetchAmount,
// Log the value without changing it.
Effect.tap((amount) => Effect.log(`amount: ${amount}`)),
// Run another effect that depends on the value.
Effect.flatMap((amount) => applyDiscount(amount, 5)),
// Transform the success value.
Effect.map((discounted) => discounted + 1),
// Replace the value with a formatted string.
Effect.map((final) => `Final amount to charge: ${final}`)
)
Effect.runFork(program) // logs "amount: 100", produces "Final amount to charge: 96"

pipe(value, f1, f2, ..., fn) feeds value into f1, the result into f2, and so on - the same as fn(...f2(f1(value))) but readable top to bottom instead of inside out. Every effect also has a .pipe method, so these two are equivalent:

import { Effect, pipe } from "effect"
const a = pipe(Effect.succeed(1), Effect.map((n) => n + 1))
const b = Effect.succeed(1).pipe(Effect.map((n) => n + 1))

The combinators below are designed for pipe: called with their options they return a function Effect => Effect. Each also has a data-first overload (Effect.map(self, f)) if you prefer to pass the effect directly.

Effect.map applies a plain function to the success value, producing a new effect. The error and requirement channels are untouched. Effects are immutable; map returns a new effect rather than mutating the original.

import { Effect } from "effect"
// ┌─── Effect<number>
// ▼
const doubled = Effect.succeed(21).pipe(Effect.map((n) => n * 2))

Effect.as(value) ignores the success value and replaces it with a constant. Effect.asVoid is the common special case that discards the value entirely.

import { Effect } from "effect"
const ready = Effect.succeed(5).pipe(Effect.as("ready" as const))
const done = Effect.log("saved").pipe(Effect.asVoid)

When the next step is itself an effect that depends on the previous value, use Effect.flatMap. It runs the inner effect and flattens the result, so you never end up with an Effect<Effect<...>>. The errors and requirements of both effects combine in the type.

import { Effect } from "effect"
const applyDiscount = (total: number, rate: number) =>
rate === 0
? Effect.fail("Discount rate cannot be zero" as const)
: Effect.succeed(total - (total * rate) / 100)
// ┌─── Effect<number, "Discount rate cannot be zero">
// ▼
const program = Effect.succeed(100).pipe(
Effect.flatMap((amount) => applyDiscount(amount, 5))
)

Make sure every effect you create inside flatMap is actually returned or chained - an effect you build but ignore simply never runs.

andThen - run the next step, value-agnostic

Section titled “andThen - run the next step, value-agnostic”

Effect.andThen sequences two steps where the second may or may not use the first’s value. The second argument is either an effect or a function returning an effect:

import { Effect } from "effect"
const fetchAmount = Effect.succeed(100)
// Function returning an effect (like flatMap)...
const a = fetchAmount.pipe(Effect.andThen((amount) => Effect.succeed(amount * 2)))
// ...or a standalone effect to run next, ignoring the previous value.
const b = fetchAmount.pipe(Effect.andThen(Effect.log("fetched")))

Use flatMap when you specifically want the transformation-of-a-value reading; use andThen when “do this, then do that” is the clearer intent.

Effect.tap runs an effect for its side effect (logging, metrics, an audit write) and then passes the original value through unchanged. If the tapped effect fails, the whole chain fails.

import { Effect } from "effect"
const applyDiscount = (total: number, rate: number) =>
Effect.succeed(total - (total * rate) / 100)
const program = Effect.succeed(100).pipe(
// Observe the amount without consuming it...
Effect.tap((amount) => Effect.log(`Applying discount to: ${amount}`)),
// ...`amount` is still available to the next step.
Effect.flatMap((amount) => applyDiscount(amount, 5))
)

Effect.all runs a collection of effects and combines their results, preserving the shape of the input - a tuple in gives a tuple out, a record in gives a record out. By default it runs sequentially and short-circuits on the first failure.

import { Effect } from "effect"
const config = Effect.succeed({ host: "localhost", port: 8080 })
const dbStatus = Effect.succeed("connected")
// ┌─── Effect<[{ host: string; port: number }, string]>
// ▼
const startup = Effect.all([config, dbStatus])
// With a record, the keys are preserved:
const named = Effect.all({ config, dbStatus })
// ▼ Effect<{ config: {...}; dbStatus: string }>

You can run the effects concurrently with the { concurrency } option - see Concurrency for the details.

APIInputOutput
mapEffect<A, E, R>, A => BEffect<B, E, R>
asEffect<A, E, R>, BEffect<B, E, R>
flatMapEffect<A, E, R>, A => Effect<B, ...>Effect<B, ...>
andThenEffect<A, E, R>, effect or A => effectEffect<B, ...>
tapEffect<A, E, R>, A => Effect<X, ...>Effect<A, ...>
all[Effect<A, ...>, Effect<B, ...>, ...]Effect<[A, B, ...], ...>

For branching and looping over effects, continue to Control flow.