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.
Expected errors (E channel)Defects (unexpected errors)The decision ruleEffect.fail(new MyError())Effect.die(message)Effect.dieMessage(text)Cause<E>Effect.catchAllDefect(effect, handler)// --- 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)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
}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 bugThis 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 typeimport { 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.Learn the operators to recover from, transform, or propagate typed errors.
Tagged errorsEffect.catchTag('NotFound', handler)Effect.catchTags({ A: handler, B: handler })Effect.catchAll(handler)Effect.mapError(f)Effect.orElse(fallback)// --- 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>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")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>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")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.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")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.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().
Effect.retry(policy)Schedule.exponential('1 second')Effect.timeout('5 seconds')Effect.timeoutFail({ ... })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.// --- 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 timeoutThe 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 = callApiimport { 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))
)
)
)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 = slowQueryimport { 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)
)
)A powerful Effect pattern: make your errors yieldable so you can use yield* to "throw" them in generators.
Data.TaggedError<Tag>()yield* new MyError()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>