Skip to content

Catching Errors

Once an error is in the E channel, you recover from it with one of the catch combinators. Each handler returns a new effect, and — crucially — handling an error removes it from the error type, so the compiler always knows which failures are still outstanding.

import { Effect, Schema } from "effect"
class ParseError extends Schema.TaggedErrorClass<ParseError>()("ParseError", {
input: Schema.String
}) {}
class ReservedPortError extends Schema.TaggedErrorClass<ReservedPortError>()(
"ReservedPortError",
{ port: Schema.Number }
) {}
declare const loadPort: (
input: string
) => Effect.Effect<number, ParseError | ReservedPortError>
// Catch BOTH tagged errors with a single `catchTag` call, returning a default.
// ┌─── Effect<number, never> — every error handled
// ▼
const recovered = loadPort("80").pipe(
Effect.catchTag(["ParseError", "ReservedPortError"], () => Effect.succeed(3000))
)

This page covers the whole error-side toolbox in three layers:

  1. Recovering — the catch* family, which removes errors from E.
  2. TransformingmapError, mapBoth, orDie, sandbox, which reshape the error channel without (necessarily) succeeding.
  3. Observing & swallowingtap*Error/tapCause to run side effects on failure, and ignore/eventually to discard failures entirely.

Effect.catchTag matches a tagged error by its _tag. The handler receives the narrowed error, so its fields are available, and the matched tag disappears from the resulting error channel.

import { Effect, Schema } from "effect"
class ValidationError extends Schema.TaggedErrorClass<ValidationError>()(
"ValidationError",
{ message: Schema.String }
) {}
class NetworkError extends Schema.TaggedErrorClass<NetworkError>()(
"NetworkError",
{ statusCode: Schema.Number }
) {}
declare const fetchUser: (
id: string
) => Effect.Effect<string, ValidationError | NetworkError>
const program = fetchUser("123").pipe(
// Handle only ValidationError; NetworkError still flows through.
Effect.catchTag("ValidationError", (error) =>
Effect.succeed(`invalid: ${error.message}`)
)
)
// program: Effect<string, NetworkError>

Pass an array of tags to handle several with the same handler, as in the opening example.

When you want a different recovery for each error, Effect.catchTags takes an object keyed by tag. Each handler again receives its narrowed error.

import { Effect, Schema } from "effect"
class ValidationError extends Schema.TaggedErrorClass<ValidationError>()(
"ValidationError",
{ message: Schema.String }
) {}
class NetworkError extends Schema.TaggedErrorClass<NetworkError>()(
"NetworkError",
{ statusCode: Schema.Number }
) {}
declare const fetchUser: (
id: string
) => Effect.Effect<string, ValidationError | NetworkError>
const userOrFallback = fetchUser("123").pipe(
Effect.catchTags({
ValidationError: (error) => Effect.succeed(`Validation failed: ${error.message}`),
NetworkError: (error) =>
Effect.succeed(`Network request failed with status ${error.statusCode}`)
})
)
// userOrFallback: Effect<string, never>

Effect.catch is the catch-all for typed failures. The handler receives the whole E union, and the error channel collapses to whatever the handler can still fail with.

import { Effect, Schema } from "effect"
class ParseError extends Schema.TaggedErrorClass<ParseError>()("ParseError", {
input: Schema.String
}) {}
class ReservedPortError extends Schema.TaggedErrorClass<ReservedPortError>()(
"ReservedPortError",
{ port: Schema.Number }
) {}
declare const loadPort: (
input: string
) => Effect.Effect<number, ParseError | ReservedPortError>
const withFinalFallback = loadPort("invalid").pipe(
// Handle one specific error first...
Effect.catchTag("ReservedPortError", () => Effect.succeed(3000)),
// ...then mop up anything still failing with a catch-all.
Effect.catch(() => Effect.succeed(3000))
)

catchIf — recover when a predicate matches

Section titled “catchIf — recover when a predicate matches”

When the discriminator isn’t a _tag, Effect.catchIf recovers based on a predicate or refinement. With a refinement the handler receives the narrowed error type; non-matching errors re-fail with the original cause.

import { Effect } from "effect"
declare const request: Effect.Effect<string, { status: number }>
const program = request.pipe(
Effect.catchIf(
(error) => error.status === 404,
() => Effect.succeed("not found, using default")
)
)

Everything above ignores defects and interruptions by design. When you genuinely need the full picture — to recover a plugin that threw, or to log a defect — reach for Effect.catchCause. The handler receives the entire Cause.

import { Cause, Effect } from "effect"
// `Effect.die` records a defect, so `catch`/`catchTag` would NOT see it.
const program = Effect.die(new Error("boom"))
const recovered = program.pipe(
Effect.catchCause((cause) =>
// Render the cause (failures + defects + interruptions) for logging.
Effect.succeed(`recovered from: ${Cause.pretty(cause)}`)
)
)

A handler is just an effect, so it can transform an error instead of fully recovering. Return a new Effect.fail to map one error to another, or Effect.die to promote it to a defect.

import { Effect, Schema } from "effect"
class LowLevelError extends Schema.TaggedErrorClass<LowLevelError>()(
"LowLevelError",
{ detail: Schema.String }
) {}
class DomainError extends Schema.TaggedErrorClass<DomainError>()("DomainError", {
message: Schema.String
}) {}
declare const lowLevel: Effect.Effect<number, LowLevelError>
const mapped = lowLevel.pipe(
// Translate an infrastructure error into a domain error.
Effect.catchTag("LowLevelError", (error) =>
Effect.fail(new DomainError({ message: `wrapped: ${error.detail}` }))
)
)
// mapped: Effect<number, DomainError>

The combinators above cover the common cases. Below is the complete recovery reference — every catch* variant in Effect, with a short runnable example. They split along two axes: what they match on (a tag, a predicate, a Filter, the whole Cause, a specific built-in error) and whether non-matching failures re-fail or pass through.

Recovers from typed failures selected by a Filter. The filter can narrow or transform the error before the handler runs; the optional orElse handles filtered-out values, otherwise they re-fail.

import { Data, Effect, Filter } from "effect"
class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {}
const program = Effect.fail(new NotFound({ id: "user-1" }))
const recovered = program.pipe(
Effect.catchFilter(
Filter.tagged("NotFound"),
(error) => Effect.succeed(`missing:${error.id}`)
)
)
// => Effect.runPromise(recovered) resolves to "missing:user-1"

Recovers from the whole Cause only when a predicate over the cause returns true. Pairs with the Cause.has* predicates (hasFails, hasDies, hasInterrupts).

import { Cause, Effect } from "effect"
const httpRequest = Effect.fail("Network Error")
const program = Effect.catchCauseIf(
httpRequest,
Cause.hasFails, // only catch causes that contain a typed failure
(cause) => Effect.succeed(`Fallback after: ${Cause.squash(cause)}`)
)
// => Effect.runPromise(program) resolves to "Fallback after: Network Error"

Like catchCauseIf, but the Filter selects (and can transform) the cause. The handler receives both the filtered value and the original Cause; filtered-out causes re-fail with the residual cause.

import { Cause, Effect, Filter } from "effect"
const task = Effect.die(new Error("boom"))
const program = task.pipe(
Effect.catchCauseFilter(
// keep only causes that contain a defect
Filter.fromPredicate(Cause.hasDies),
(_value, cause) => Effect.succeed(`contained a defect: ${Cause.squash(cause)}`)
)
)
// => Effect.runPromise(program) resolves to "contained a defect: Error: boom"

Recovers from defects (thrown exceptions or values passed to Effect.die) while leaving typed failures and interruptions in place. Use sparingly, at integration boundaries where a thrown bug must be contained.

import { Effect } from "effect"
// A thrown error becomes a defect, invisible to `catch`/`catchTag`.
const program = Effect.sync(() => {
throw new Error("plugin crashed")
})
const recovered = program.pipe(
Effect.catchDefect((defect) => Effect.succeed(`contained: ${defect}`))
)
// => Effect.runPromise(recovered) resolves to "contained: Error: plugin crashed"

Catches Cause.NoSuchElementError specifically — the error produced by Effect.fromNullishOr, Effect.fromOption, and Option-yielding gens — and converts the result into an Option. Success becomes Option.some, the missing error becomes Option.none, all other errors are preserved.

import { Effect } from "effect"
const some = Effect.fromNullishOr(1).pipe(Effect.catchNoSuchElement)
const none = Effect.fromNullishOr(null).pipe(Effect.catchNoSuchElement)
Effect.runPromise(some).then(console.log) // => { _id: 'Option', _tag: 'Some', value: 1 }
Effect.runPromise(none).then(console.log) // => { _id: 'Option', _tag: 'None' }

When a tagged error nests a reason error inside one of its fields, these combinators let you recover from (or promote) the inner reason without unpacking the wrapper by hand. unwrapReason replaces the parent error in E with its inner reason union.

import { Data, Effect } from "effect"
class RateLimitError extends Data.TaggedError("RateLimitError")<{
retryAfter: number
}> {}
class AiError extends Data.TaggedError("AiError")<{
reason: RateLimitError
}> {}
declare const program: Effect.Effect<string, AiError>
// Before: Effect<string, AiError>
// After: Effect<string, RateLimitError>
const unwrapped = program.pipe(Effect.unwrapReason("AiError"))

These three are covered in depth on the Reason Errors page, which owns the pattern.

Sometimes you do not want to recover — you want to reshape the error channel: translate one error type to another, collapse a typed failure into an unrecoverable defect, or expose the whole Cause so you can pattern-match it.

Transforms the failure value while leaving the success value untouched. The effect still fails with the new error type; it does not recover.

import { Data, Effect } from "effect"
class TaskError extends Data.TaggedError("TaskError")<{ message: string }> {}
// ┌─── Effect<number, string>
// ▼
const task = Effect.fail("Oh no!").pipe(Effect.as(1))
// ┌─── Effect<number, TaskError>
// ▼
const mapped = Effect.mapError(task, (message) => new TaskError({ message }))

Transforms both channels at once, without changing whether the effect succeeds or fails. Takes onFailure and onSuccess.

import { Data, Effect } from "effect"
class TaskError extends Data.TaggedError("TaskError")<{ message: string }> {}
// ┌─── Effect<number, string>
// ▼
const task = Effect.fail("Oh no!").pipe(Effect.as(1))
// ┌─── Effect<boolean, TaskError>
// ▼
const modified = Effect.mapBoth(task, {
onFailure: (message) => new TaskError({ message }),
onSuccess: (n) => n > 0
})

Turns any typed failure into an unrecoverable defect, emptying the E channel to never. Use it when a typed failure actually represents a bug that no caller should handle.

import { Data, Effect } from "effect"
class DivideByZeroError extends Data.TaggedError("DivideByZeroError")<{}> {}
const divide = (a: number, b: number) =>
b === 0 ? Effect.fail(new DivideByZeroError()) : Effect.succeed(a / b)
// ┌─── Effect<number, never> — error channel emptied
// ▼
const program = Effect.orDie(divide(1, 0))
Effect.runPromise(program).catch(console.error)
// => (FiberFailure) DivideByZeroError ...

Moves the entire Cause into the error channel, so E becomes Cause<E>. Now a single catch/catchTag can pattern-match failures, defects, and interruptions uniformly. Recover (or re-fail) and the cause structure collapses back as usual.

import { Cause, Effect } from "effect"
const task = Effect.die(new Error("boom"))
// ┌─── Effect<never, Cause<never>>
// ▼
const sandboxed = Effect.sandbox(task)
const recovered = sandboxed.pipe(
// The error is now a full Cause we can inspect.
Effect.catch((cause) =>
Cause.hasDies(cause)
? Effect.succeed(`contained defect: ${Cause.squash(cause)}`)
: Effect.failCause(cause)
)
)
// => Effect.runPromise(recovered) resolves to "contained defect: Error: boom"

These are the failure-side mirror of the tap operators in pipelines: they run a side effect when the effect fails, then re-propagate the original failure unchanged. If the side effect itself fails, that error is added to the channel.

Runs an effect on a typed failure, then keeps failing with the original error. Ideal for logging or metrics.

import { Console, Effect } from "effect"
const task: Effect.Effect<number, string> = Effect.fail("NetworkError")
const tapping = Effect.tapError(task, (error) =>
Console.log(`expected error: ${error}`)
)
// => logs "expected error: NetworkError", then still fails with "NetworkError"

Taps only when the failure’s _tag matches (one tag or an array of tags). The handler receives the narrowed error.

import { Console, Data, Effect } from "effect"
class NetworkError extends Data.TaggedError("NetworkError")<{
statusCode: number
}> {}
const task: Effect.Effect<number, NetworkError> = Effect.fail(
new NetworkError({ statusCode: 504 })
)
const program = Effect.tapErrorTag(task, "NetworkError", (error) =>
Console.log(`status: ${error.statusCode}`)
)
// => logs "status: 504", then still fails with the NetworkError

Taps the entire Cause, so it observes typed failures, defects, and interruptions. Use it for catch-all failure logging.

import { Cause, Console, Effect } from "effect"
const task = Effect.fail("Something went wrong")
const program = Effect.tapCause(task, (cause) =>
Console.log(`Logging cause: ${Cause.squash(cause)}`)
)
// => logs "Logging cause: Something went wrong", then still fails

Taps the Cause only when a predicate over it returns true.

import { Cause, Console, Effect } from "effect"
const task = Effect.fail("Network timeout")
const program = Effect.tapCauseIf(
task,
Cause.hasFails, // skip interrupts and defects
(cause) => Console.log(`failure cause: ${Cause.squash(cause)}`)
)
// => logs "failure cause: Network timeout", then still fails

Taps the Cause only when a Filter selects it; the side effect receives the selected value and the original cause.

import { Cause, Console, Effect, Filter } from "effect"
const task = Effect.die(new Error("boom"))
const program = task.pipe(
Effect.tapCauseFilter(
Filter.fromPredicate(Cause.hasDies),
(_value, cause) => Console.log(`defect: ${Cause.squash(cause)}`)
)
)
// => logs "defect: Error: boom", then still fails with the defect

Taps only defects (thrown exceptions / Effect.die). Recoverable typed failures are ignored.

import { Console, Effect } from "effect"
const recoverable: Effect.Effect<number, string> = Effect.fail("NetworkError")
const fatal: Effect.Effect<number> = Effect.die("Something went wrong")
Effect.runFork(Effect.tapDefect(recoverable, (d) => Console.log(`defect: ${d}`)))
// => no output (NetworkError is not a defect)
Effect.runFork(Effect.tapDefect(fatal, (d) => Console.log(`defect: ${d}`)))
// => logs "defect: RuntimeException: Something went wrong"

When you want a failing effect to not propagate its failure at all — run it for its side effects, or just keep trying until it works.

Discards both the success and the failure, producing Effect<void, never, R>. Pass { log: true } (and optionally message) to emit the Cause to the logger when the effect fails.

import { Effect } from "effect"
// ┌─── Effect<number, string>
// ▼
const task = Effect.fail("Uh oh!").pipe(Effect.as(5))
// ┌─── Effect<void, never>
// ▼
const program = task.pipe(Effect.ignore)
// Variant that still logs the failure as a warning before discarding it:
const logged = task.pipe(Effect.ignore({ log: "Warn", message: "ignoring failure" }))

Like ignore, but also swallows the full Cause, including defects and interruptions — not just typed failures. Supports the same log / message options.

import { Effect } from "effect"
const task = Effect.die("boom") // a defect
// ┌─── Effect<void, never>
// ▼
const program = task.pipe(Effect.ignoreCause)
const logged = task.pipe(Effect.ignoreCause({ log: true, message: "ignoring cause" }))

Retries the effect until it succeeds, discarding every failure along the way, and yielding between attempts so other fibers can run. The result has E = never.

import { Console, Effect } from "effect"
let attempts = 0
const flaky = Effect.gen(function* () {
attempts++
yield* Console.log(`Attempt ${attempts}`)
if (attempts < 3) return yield* Effect.fail("Not ready")
return "Ready"
})
const program = Effect.eventually(flaky)
// => runs until success: "Attempt 1", "Attempt 2", "Attempt 3", resolves to "Ready"