pipe is the other main way to compose Effects (besides generators). If you've ever chained .then().then().catch() on Promises, pipe is the same idea — but with separate operators for each concern.
pipe(value, fn1, fn2, ...) — standaloneeffect.pipe(fn1, fn2, ...) — methodEffect.map(f)Effect.flatMap(f)Effect.andThen(f)Effect.tap(f)// Two ways to pipe — same result, different syntax:
// --- Style 1: standalone pipe() (import { pipe } from "effect") ---
import { pipe, Effect } from "effect"
const program1 = pipe(
fetchUser(id),
Effect.map((user) => user.name),
Effect.flatMap((name) => saveGreeting(name))
)
// --- Style 2: .pipe() method on the Effect value ---
const program2 = fetchUser(id).pipe(
Effect.map((user) => user.name),
Effect.flatMap((name) => saveGreeting(name))
)
// Both produce the same Effect. Most Effect code uses .pipe()
// because it reads like method chaining and needs no extra import.
// Full example with cross-cutting concerns:
const resilient = fetchUser(id).pipe(
Effect.map((user) => user.name),
Effect.tap((name) => Effect.log(name)),
Effect.flatMap((name) => saveGreeting(name)),
Effect.timeout("5 seconds"),
Effect.retry({ times: 3 })
)Convert this Promise chain to an Effect pipeline using pipe. Think about which operator replaces each .then().
// Promise version
fetch("/api/user")
.then(res => res.json()) // returns a Promise → flatMap
.then(user => user.name.toUpperCase()) // returns a value → map
.then(name => console.log(name)) // side effect → tap
// TODO: rewrite with pipe
import { pipe, Effect, Console } from "effect"
const program = pipe(
Effect.tryPromise(() => fetch("/api/user")),
// your code here
)import { pipe, Effect, Console } from "effect"
const program = pipe(
Effect.tryPromise(() => fetch("/api/user")),
// res.json() is async + can fail → flatMap with tryPromise
Effect.flatMap((res) =>
Effect.tryPromise(() => res.json())
),
// .name.toUpperCase() is sync transform → map
Effect.map((user) => user.name.toUpperCase()),
// console.log is a side effect → tap (value passes through)
Effect.tap((name) => Console.log(name))
)
// The rule: returns an Effect? → flatMap. Returns a value? → map.
// Or just use andThen for everything — it auto-detects.Fix the type errors. Each operator has a specific contract — using the wrong one won't compile.
import { pipe, Effect } from "effect"
const getUser = Effect.succeed({ name: "Alice", id: 1 })
const fetchPosts = (id: number) =>
Effect.succeed(["post1", "post2"])
// BUG: map expects a plain value, but fetchPosts returns an Effect
const posts = pipe(
getUser,
Effect.map((user) => fetchPosts(user.id))
)
// posts is Effect<Effect<string[]>> — double wrapped!
// TODO: fix the operator choice so posts is Effect<string[]>import { pipe, Effect } from "effect"
const getUser = Effect.succeed({ name: "Alice", id: 1 })
const fetchPosts = (id: number) =>
Effect.succeed(["post1", "post2"])
// Fix 1: use flatMap — it "unwraps" the inner Effect
const posts = pipe(
getUser,
Effect.flatMap((user) => fetchPosts(user.id))
)
// posts is Effect<string[]> ✓
// Fix 2: use andThen — it auto-detects that fetchPosts returns an Effect
const posts2 = pipe(
getUser,
Effect.andThen((user) => fetchPosts(user.id))
)
// andThen works like map AND flatMap — pick it when you don't want to think about itEffect gives you two styles. Imperative: normal if/else and for-loops inside Effect.gen — you already know this from TS. Declarative: Effect.if, Effect.forEach, Effect.all — describe WHAT should happen, not HOW. Imperative is better when logic is complex with many branches, early returns, or intermediate variables — it reads like regular TS. Declarative wins when you need composability: swapping sequential for concurrent is a one-line option change, and operators chain cleanly in pipes without nesting.
Effect.if(condition, { onTrue, onFalse })Effect.forEach(items, fn)Effect.all([...effects])Effect.match / matchEffect// Normal JS control flow works in generators
const program = Effect.gen(function* () {
const user = yield* getUser(id)
if (user.role === "admin") {
yield* grantAccess()
}
// Effect.all — sequential by default!
const [posts, comments] = yield* Effect.all(
[fetchPosts(user.id), fetchComments(user.id)],
{ concurrency: "unbounded" } // parallel
)
})You're used to Promise.all running everything concurrently. Effect.all is different. Predict the output order, then fix the code to run concurrently.
import { Effect } from "effect"
const task = (label: string, ms: number) =>
Effect.gen(function* () {
yield* Effect.sleep(`${ms} millis`)
yield* Effect.log(label)
return label
})
// What order do the logs print?
const program = Effect.all([
task("A", 100),
task("B", 50),
task("C", 10),
])
// (A) A, B, C (B) C, B, A (C) all at once
// TODO: make them run concurrently so fastest finishes firstimport { Effect } from "effect"
const task = (label: string, ms: number) =>
Effect.gen(function* () {
yield* Effect.sleep(`${ms} millis`)
yield* Effect.log(label)
return label
})
// Answer: (A) A, B, C — sequential by default!
// Effect.all runs them one by one, in order.
// Fix: add concurrency option
const program = Effect.all(
[task("A", 100), task("B", 50), task("C", 10)],
{ concurrency: "unbounded" }
)
// Now prints: C, B, A (fastest first)
// You can also use { concurrency: 2 } to limit parallelism —
// something Promise.all can't do without extra libraries.Convert this imperative loop into Effect.forEach. Think about what the callback returns and what the final result looks like.
import { Effect } from "effect"
const sendEmail = (to: string) =>
Effect.succeed(`sent to ${to}`)
// Imperative version inside Effect.gen
const imperative = Effect.gen(function* () {
const users = ["alice@x.com", "bob@x.com", "carol@x.com"]
const results: string[] = []
for (const user of users) {
const result = yield* sendEmail(user)
results.push(result)
}
return results
})
// TODO: rewrite using Effect.forEach in one line
const declarative = Effect.forEach(/* ??? */)import { Effect } from "effect"
const sendEmail = (to: string) =>
Effect.succeed(`sent to ${to}`)
// Effect.forEach maps over items, running an Effect for each,
// and collects all success values into an array.
const declarative = Effect.forEach(
["alice@x.com", "bob@x.com", "carol@x.com"],
(user) => sendEmail(user)
)
// Type: Effect<string[], never, never>
// Returns: ["sent to alice@x.com", "sent to bob@x.com", "sent to carol@x.com"]
// Like Effect.all, it's sequential by default.
// Add { concurrency: "unbounded" } to send in parallel:
const parallel = Effect.forEach(
["alice@x.com", "bob@x.com", "carol@x.com"],
(user) => sendEmail(user),
{ concurrency: "unbounded" }
)Rewrite the generator's if/else as a pipeline using Effect.if. When would you prefer one over the other?
import { Effect } from "effect"
const isAdmin = (role: string) => role === "admin"
// Generator style — normal TS if/else
const checkAccess = (role: string) =>
Effect.gen(function* () {
if (isAdmin(role)) {
return yield* Effect.succeed("full access")
} else {
return yield* Effect.succeed("read only")
}
})
// TODO: rewrite as a pipeline using Effect.if
const checkAccessPipe = (role: string) =>
Effect.if(/* ??? */)import { Effect } from "effect"
const isAdmin = (role: string) => role === "admin"
const checkAccessPipe = (role: string) =>
Effect.if(isAdmin(role), {
onTrue: () => Effect.succeed("full access"),
onFalse: () => Effect.succeed("read only"),
})
// Type: Effect<string, never, never>
// When to use which?
// - In a generator: just use normal if/else — it's simpler
// - In a pipe chain: Effect.if keeps the pipeline flat
// Both are valid. Don't force Effect.if where plain if/else reads better.Use Effect.match to convert a fallible Effect into a user-friendly message string, handling both success and failure without try/catch.
import { Effect } from "effect"
const parseAge = (input: string) =>
Effect.try({
try: () => {
const n = Number(input)
if (isNaN(n)) throw new Error("not a number")
return n
},
catch: () => new Error("invalid age"),
})
// TODO: use Effect.match to produce a string message for both cases
// Success → "Your age is 25"
// Failure → "Error: invalid age"
const getMessage = (input: string) =>
parseAge(input).pipe(
Effect.match({
// ???
})
)import { Effect } from "effect"
const parseAge = (input: string) =>
Effect.try({
try: () => {
const n = Number(input)
if (isNaN(n)) throw new Error("not a number")
return n
},
catch: () => new Error("invalid age"),
})
// Effect.match handles BOTH success and failure,
// collapsing them into a single non-failing Effect.
const getMessage = (input: string) =>
parseAge(input).pipe(
Effect.match({
onSuccess: (age) => `Your age is ${age}`,
onFailure: (err) => `Error: ${err.message}`,
})
)
// Type: Effect<string, never, never> — the error channel is gone!
// Effect.runSync(getMessage("25")) → "Your age is 25"
// Effect.runSync(getMessage("abc")) → "Error: invalid age"
// match returns a plain value → the result can't fail.
// matchEffect returns an Effect → use it when your handler needs to do more Effects.