Effect TS

A course for TypeScript developers

Error Handling

4 steps
07
The Two Error Types
Expected vs Unexpected — the core insight
20 min

In TypeScript, try/catch treats all errors the same — you never know what a function might throw. Effect splits errors into two categories. The rule is simple: could a user or the outside world cause this error? → Effect.fail (expected). Should it be impossible if the code is correct? → Effect.die (defect). You don't use Effect.die because you expect a bug — you use it as a safety net for things you believe are impossible, so if you're wrong, you crash loudly with a clear message instead of silently continuing with bad state.

Key InsightExpected errors (Effect.fail) go in the E type — the compiler forces callers to handle them. Defects (Effect.die) guard invariants — conditions that should never happen if the code is correct. They're not predictions of bugs, they're safety nets. When one fires, you get a clear message + full fiber trace in Cause, making it easy to debug.
What to learn
Expected errors (E channel)
Things that CAN happen in normal operation: user not found, invalid input, network timeout. They're in the type signature. Callers must handle them.
Defects (unexpected errors)
Safety nets for things you believe are impossible if the code is correct: an unreachable switch case, a missing config at startup, corrupted internal state. You don't predict these — you guard against them so you crash loudly instead of silently continuing.
The decision rule
Ask: should this be impossible if my code is correct? Yes → Effect.die (guard the invariant). No, it can happen in normal operation → Effect.fail (let the caller handle it).
Effect.fail(new MyError())
Creates an expected error. It shows up in the type: Effect<never, MyError, never>
Effect.die(message)
Creates a defect. NOT in the type: Effect<never, never, never>. Use when the error means your program has a bug.
Effect.dieMessage(text)
Convenience for Effect.die(new RuntimeException(text)). Use for quick defects with a message.
Cause<E>
The data type that tracks the full error history — failures, defects, interruptions, and even parallel/sequential combinations of errors.
Effect.catchAllDefect(effect, handler)
Catches defects and lets you recover. Use sparingly — defects usually mean a bug you should fix, not catch.
Example
// --- The question: could a user cause this error? ---

// YES → Effect.fail (expected, in the type)
const findUser = (id: string) =>
  id === "0"
    ? Effect.fail(new UserNotFound({ id }))
    : Effect.succeed({ id, name: "Alice" })
// Type: Effect<User, UserNotFound, never>
// A missing user is normal — the caller should handle it

// NO → Effect.die (defect, NOT in the type)
// A switch that should be exhaustive hitting default:
type Status = "active" | "inactive"
const label = (s: Status) => {
  switch (s) {
    case "active": return Effect.succeed("Active")
    case "inactive": return Effect.succeed("Inactive")
    default: return Effect.die(`Unhandled status: ${s}`)
    // If this runs, a developer forgot to add a case.
    // No user action or retry can fix this.
  }
}
// Type: Effect<string, never, never>

// More defect examples:
// - Required env var missing at startup → die (deployment is broken)
// - JSON.parse fails on data YOU generated → die (your code is wrong)
// - Array access after you just checked length → die (logic bug)

// More expected error examples:
// - API returns 404 → fail (normal, show "not found" to user)
// - User submits invalid email → fail (validate and show message)
// - Network timeout → fail (retry or show error to user)
Practice
Classify the errors

A function fetches a user from an API. It can fail because the user doesn't exist (404) or because of a JSON parse error. Create the function using Effect.fail for the expected error and Effect.die for the defect.

import { Effect } from "effect"

class UserNotFound {
  readonly _tag = "UserNotFound" as const
  constructor(readonly id: string) {}
}

// TODO: implement fetchUser
// - If id is "missing", fail with UserNotFound
// - If id is "corrupt", die with "Invalid JSON response"
// - Otherwise, succeed with { id, name: "Alice" }
const fetchUser = (id: string) => {
  // your code here
}
Reveal solution
import { Effect } from "effect"

class UserNotFound {
  readonly _tag = "UserNotFound" as const
  constructor(readonly id: string) {}
}

const fetchUser = (id: string) => {
  if (id === "missing") return Effect.fail(new UserNotFound("missing"))
  if (id === "corrupt") return Effect.die("Invalid JSON response")
  return Effect.succeed({ id, name: "Alice" })
}
// Type: Effect<{ id: string; name: string }, UserNotFound, never>
// UserNotFound is visible in the type — callers must handle it
// The JSON defect is NOT in the type — it's a bug
Spot the mistake

This code uses Effect.fail for a missing config value at startup. A missing config means the deployment is broken — no user action can fix it. Refactor it to use the correct error type.

import { Effect } from "effect"

class MissingConfig {
  readonly _tag = "MissingConfig" as const
  constructor(readonly key: string) {}
}

// This is wrong — a missing config at startup is not something
// callers should handle. It means the deployment is broken.
const getConfig = (key: string) => {
  const value = process.env[key]
  return value === undefined
    ? Effect.fail(new MissingConfig(key))
    : Effect.succeed(value)
}

// Fix it: use the right error type
Reveal solution
import { Effect } from "effect"

// A missing required config means the program can't run.
// A developer needs to fix the deployment — this is a defect.
const getConfig = (key: string) => {
  const value = process.env[key]
  return value === undefined
    ? Effect.die(`Missing required config: ${key}`)
    : Effect.succeed(value)
}
// Type: Effect<string, never, never>
// No error in the type — if this fails, fix the deployment.
Common TrapDon't make everything an expected error. Use expected errors for things callers should handle (network failures, validation). Use defects for programming bugs. Also don't routinely catch defects — if you find yourself using catchAllDefect often, you're probably misclassifying errors.
Read docs →
08
Handling Expected Errors
catchTag, catchAll, mapError
20 min

Learn the operators to recover from, transform, or propagate typed errors.

Key InsightUse tagged errors (classes with a _tag field) + catchTag for surgical error recovery. This is the Effect equivalent of catching specific exception types.
What to learn
Tagged errors
Create error classes with _tag: class NotFound { readonly _tag = 'NotFound' }. This enables catchTag.
Effect.catchTag('NotFound', handler)
Catches only errors with that _tag. Other errors pass through. The E type updates automatically — the caught error disappears from the union.
Effect.catchTags({ A: handler, B: handler })
Handle multiple tagged errors at once. Each key is a _tag, each value is a handler. Cleaner than chaining multiple catchTag calls.
Effect.catchAll(handler)
Catches all expected errors regardless of tag. You receive the error union and must return a new Effect.
Effect.mapError(f)
Transforms the error without handling it — the error stays in E, just with a different type. Useful for wrapping low-level errors into domain errors.
Effect.orElse(fallback)
If the Effect fails, discard the error and run a completely different Effect instead.
Example
// --- Setup: tagged error classes + a function that can fail ---
class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class Forbidden { readonly _tag = "Forbidden" as const }

const getResource = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "secret") return Effect.fail(new Forbidden())
  return Effect.succeed({ id, name: "Resource" })
}
// Type: Effect<Resource, NotFound | Forbidden, never>

// --- catchTag: handle ONE specific error ---
const withFallback = getResource("missing").pipe(
  Effect.catchTag("NotFound", ({ id }) =>
    Effect.succeed({ id, name: "Default" })
  )
)
// Type: Effect<Resource, Forbidden, never>
// NotFound is gone from the type! Forbidden still needs handling.

// --- catchTags: handle MULTIPLE errors at once ---
const withAllHandled = getResource("missing").pipe(
  Effect.catchTags({
    NotFound: ({ id }) => Effect.succeed({ id, name: "Default" }),
    Forbidden: () => Effect.succeed({ id: "0", name: "Guest" }),
  })
)
// Type: Effect<Resource, never, never>
// All errors handled — no E left.

// --- catchAll: handle ALL expected errors (any tag) ---
const withCatchAll = getResource("missing").pipe(
  Effect.catchAll((error) =>
    Effect.succeed({ id: "0", name: `Fallback (${error._tag})` })
  )
)
// Type: Effect<Resource, never, never>

// --- mapError: transform without handling ---
class AppError { readonly _tag = "AppError" as const; constructor(readonly cause: unknown) {} }

const withWrapped = getResource("missing").pipe(
  Effect.mapError((e) => new AppError(e))
)
// Type: Effect<Resource, AppError, never>
// Error is still there, just wrapped. Caller still must handle it.

// --- orElse: swap to a completely different Effect ---
const withOrElse = getResource("missing").pipe(
  Effect.orElse(() => Effect.succeed({ id: "0", name: "Fallback" }))
)
// Type: Effect<Resource, never, never>
Practice
Use catchTag to handle one error

The program below can fail with NotFound or RateLimited. Use catchTag to handle ONLY NotFound by returning a default post. Let RateLimited pass through.

import { Effect } from "effect"

class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class RateLimited { readonly _tag = "RateLimited" as const }

const getPost = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "busy") return Effect.fail(new RateLimited())
  return Effect.succeed({ id, title: "Hello World" })
}

// TODO: handle only NotFound, return { id, title: "Default Post" }
// RateLimited should still be in the error type
const program = getPost("missing")
Reveal solution
import { Effect } from "effect"

class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class RateLimited { readonly _tag = "RateLimited" as const }

const getPost = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "busy") return Effect.fail(new RateLimited())
  return Effect.succeed({ id, title: "Hello World" })
}

const program = getPost("missing").pipe(
  Effect.catchTag("NotFound", ({ id }) =>
    Effect.succeed({ id, title: "Default Post" })
  )
)
// Type: Effect<{ id: string; title: string }, RateLimited, never>
Wrap errors with mapError

Use mapError to wrap both NotFound and RateLimited into a single AppError type. Don't handle the errors — just transform them.

import { Effect } from "effect"

class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class RateLimited { readonly _tag = "RateLimited" as const }
class AppError { readonly _tag = "AppError" as const; constructor(readonly message: string) {} }

const getPost = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "busy") return Effect.fail(new RateLimited())
  return Effect.succeed({ id, title: "Hello World" })
}

// TODO: use mapError to wrap all errors into AppError
// Hint: use the _tag to build a descriptive message
const program = getPost("missing")
Reveal solution
import { Effect } from "effect"

class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class RateLimited { readonly _tag = "RateLimited" as const }
class AppError { readonly _tag = "AppError" as const; constructor(readonly message: string) {} }

const getPost = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "busy") return Effect.fail(new RateLimited())
  return Effect.succeed({ id, title: "Hello World" })
}

const program = getPost("missing").pipe(
  Effect.mapError((e) => new AppError(
    e._tag === "NotFound"
      ? `Post ${e.id} not found`
      : "Too many requests"
  ))
)
// Type: Effect<{ id: string; title: string }, AppError, never>
// Error is still there — just unified into one type.
Handle all errors with catchTags

Use catchTags to handle both NotFound and RateLimited in a single call. Return a fallback post for NotFound and retry message for RateLimited.

import { Effect } from "effect"

class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class RateLimited { readonly _tag = "RateLimited" as const }

const getPost = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "busy") return Effect.fail(new RateLimited())
  return Effect.succeed({ id, title: "Hello World" })
}

// TODO: use catchTags to handle both errors
// NotFound → succeed with { id, title: "Default Post" }
// RateLimited → succeed with { id: "0", title: "Try again later" }
const program = getPost("missing")
Reveal solution
import { Effect } from "effect"

class NotFound { readonly _tag = "NotFound" as const; constructor(readonly id: string) {} }
class RateLimited { readonly _tag = "RateLimited" as const }

const getPost = (id: string) => {
  if (id === "missing") return Effect.fail(new NotFound(id))
  if (id === "busy") return Effect.fail(new RateLimited())
  return Effect.succeed({ id, title: "Hello World" })
}

const program = getPost("missing").pipe(
  Effect.catchTags({
    NotFound: ({ id }) =>
      Effect.succeed({ id, title: "Default Post" }),
    RateLimited: () =>
      Effect.succeed({ id: "0", title: "Try again later" }),
  })
)
// Type: Effect<{ id: string; title: string }, never, never>
// Both errors handled — E is never.
Common TrapcatchTag only works with tagged unions. If your errors don't have _tag, use catchAll or match instead.
Read docs →
09
Retrying & Timeouts
Schedule-based resilience
15 min

In TypeScript, retrying means a while loop with try/catch, manual delay, and a counter. Timeouts mean wrapping everything in Promise.race with a setTimeout. It's messy, error-prone, and doesn't compose. Effect gives you declarative retry and timeout that snap onto any Effect with .pipe().

Key InsightRetries use Schedule — a composable description of "when to retry". Timeouts add a new error type to your Effect's E channel automatically.
What to learn
Effect.retry(policy)
Retries on failure using a Schedule. The schedule controls timing and max attempts.
Schedule.exponential('1 second')
Exponential backoff schedule. Compose with && or || for complex policies.
Effect.timeout('5 seconds')
Fails with a TimeoutException if the effect takes too long.
Effect.timeoutFail({ ... })
Like timeout but with a custom error type.
In TypeScript
async function fetchWithRetry(url: string) {
  for (let i = 0; i < 3; i++) {
    try {
      const controller = new AbortController()
      const id = setTimeout(() => controller.abort(), 5000)
      const res = await fetch(url, { signal: controller.signal })
      clearTimeout(id)
      return await res.json()
    } catch (e) {
      if (i === 2) throw e
      await new Promise((r) => setTimeout(r, 1000 * 2 ** i))
    }
  }
}
// 15 lines. Manual loop, manual backoff, manual abort.
// Want to change the strategy? Rewrite the whole function.
With Effect
// --- Effect.retry: retry on failure ---
// Simplest: retry up to 3 times
const retried = fetchData.pipe(
  Effect.retry({ times: 3 })
)

// With a Schedule: exponential backoff, max 3 attempts
const withBackoff = fetchData.pipe(
  Effect.retry(
    Schedule.exponential("1 second").pipe(
      Schedule.compose(Schedule.recurs(3))
    )
  )
)
// Waits 1s, 2s, 4s between retries

// Fixed delay between retries
const withFixed = fetchData.pipe(
  Effect.retry(Schedule.fixed("500 millis"))
)

// --- Effect.timeout: fail if too slow ---
const withTimeout = fetchData.pipe(
  Effect.timeout("5 seconds")
)
// Type adds TimeoutException to the E channel automatically

// --- Effect.timeoutFail: custom error on timeout ---
class MyTimeout { readonly _tag = "MyTimeout" as const }

const withCustomTimeout = fetchData.pipe(
  Effect.timeoutFail({
    duration: "5 seconds",
    onTimeout: () => new MyTimeout()
  })
)
// Type: Effect<Data, OriginalError | MyTimeout, never>

// --- Composing them together ---
const resilient = fetchData.pipe(
  Effect.timeout("5 seconds"),
  Effect.retry({ times: 3 }),
  Effect.catchTag("TimeoutException", () =>
    Effect.succeed(cachedData)
  )
)
// timeout each attempt, retry up to 3 times, fallback on timeout
Practice
Add retry with backoff

The API call below is flaky. Add a retry policy with exponential backoff starting at 500ms, max 3 attempts.

import { Effect, Schedule } from "effect"

const callApi = Effect.tryPromise(() =>
  fetch("https://api.example.com/data").then((r) => r.json())
)

// TODO: add retry with exponential backoff
// - Start at 500ms
// - Max 3 attempts
const program = callApi
Reveal solution
import { Effect, Schedule } from "effect"

const callApi = Effect.tryPromise(() =>
  fetch("https://api.example.com/data").then((r) => r.json())
)

const program = callApi.pipe(
  Effect.retry(
    Schedule.exponential("500 millis").pipe(
      Schedule.compose(Schedule.recurs(3))
    )
  )
)
Timeout with fallback

Add a 3-second timeout to the slow effect. If it times out, return the cached value instead of failing.

import { Effect } from "effect"

const slowQuery = Effect.tryPromise(() =>
  fetch("https://api.example.com/slow")
)
const cachedResult = { data: "cached" }

// TODO: add a 3-second timeout
// If it times out, return cachedResult
const program = slowQuery
Reveal solution
import { Effect } from "effect"

const slowQuery = Effect.tryPromise(() =>
  fetch("https://api.example.com/slow")
)
const cachedResult = { data: "cached" }

const program = slowQuery.pipe(
  Effect.timeout("3 seconds"),
  Effect.catchTag("TimeoutException", () =>
    Effect.succeed(cachedResult)
  )
)
Common Traptimeout adds TimeoutException to your error channel. If you want to handle it specifically, use catchTag('TimeoutException', ...).
Read docs →
10
Yieldable Errors
Errors as first-class values
15 min

A powerful Effect pattern: make your errors yieldable so you can use yield* to "throw" them in generators.

Key InsightExtend Data.TaggedError to create error classes that are both proper data types AND yieldable inside Effect.gen.
What to learn
Data.TaggedError<Tag>()
Creates an error class with _tag, structural equality, and yieldability built in.
yield* new MyError()
Inside a generator, yielding a TaggedError short-circuits with that error — like throwing but type-safe.
Example
class NotFound extends Data.TaggedError("NotFound")<{
  readonly id: string
}> {}

const getUser = (id: string) => Effect.gen(function* () {
  const user = yield* findUser(id)
  if (!user) yield* new NotFound({ id }) // type-safe "throw"
  return user
})
// Type: Effect<User, NotFound, Database>
Common TrapYou need to extend Data.TaggedError, not just add a _tag manually, for the yield* trick to work.
Read docs →
CompositionDependency Injection