Effect TS

A course for TypeScript developers

Composition

2 steps
05
pipe & Pipelines
The functional composition backbone
15 min

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.

Key Insightpipe reads top-to-bottom like method chaining. In TS you write promise.then(f).then(g). In Effect you write pipe(effect, Effect.map(f), Effect.flatMap(g)). Same flow, explicit operators.
What to learn
pipe(value, fn1, fn2, ...) — standalone
Imported from 'effect'. Passes value through fn1, then fn2, etc. Works with any value, not just Effects.
effect.pipe(fn1, fn2, ...) — method
Every Effect value has a .pipe() method. Same result, but reads like method chaining. No import needed. Most code uses this style.
Effect.map(f)
Transforms the success value. Like .then(x => x * 2) — f returns a plain value, not an Effect.
Effect.flatMap(f)
Chains Effects. f returns a new Effect. Like .then(x => fetch(...)) where .then receives another Promise.
Effect.andThen(f)
Unified map + flatMap. Pass a value, a function, or an Effect — it figures out the right behavior. The most flexible operator.
Effect.tap(f)
Runs a side effect without changing the value. The value passes through unchanged. Great for logging.
Example
// 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 })
)
Practice
Translate Promise chains to pipe

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
)
Reveal solution
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.
map vs flatMap vs andThen

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[]>
Reveal solution
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 it
Common TrapWhen to use pipe vs gen? Use gen for complex sequential logic (like async/await). Use pipe for adding behaviors to an existing Effect (retry, timeout, logging). They compose together — build your logic with gen, then wrap it in pipe to add cross-cutting concerns.
Read docs →
06
Control Flow
if/else, loops, matching in Effect
15 min

Effect 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.

Key InsightImperative (generators): best for complex branching, early returns, and readable step-by-step logic. Declarative (operators): best for composable pipelines where you want to add concurrency, retries, or timeouts without restructuring. You don't have to choose one — mix both in the same program.
What to learn
Effect.if(condition, { onTrue, onFalse })
Declarative conditional. Same as if/else in a generator, but composable in a pipeline.
Effect.forEach(items, fn)
Declarative loop. Like items.map(fn) but each fn returns an Effect. Sequential by default.
Effect.all([...effects])
Run multiple Effects and collect results. Like Promise.all but sequential by default — opt into concurrency explicitly.
Effect.match / matchEffect
Declarative success/failure handling. match returns a plain value (like a ternary), matchEffect returns a new Effect.
Example
// 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
  )
})
Practice
Promise.all vs Effect.all

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 first
Reveal solution
import { 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.
Rewrite a for-loop with Effect.forEach

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(/* ??? */)
Reveal solution
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" }
)
Effect.if — conditional without if/else

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(/* ??? */)
Reveal solution
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.
Effect.match — handle both outcomes

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({
      // ???
    })
  )
Reveal solution
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.
Common TrapEffect.all is sequential by default! For parallel execution, pass { concurrency: "unbounded" } or a number. This is the opposite of Promise.all which is always concurrent.
Read docs →
FoundationError Handling