Skip to content

Reason Errors

Sometimes a single operation can fail in several related ways, and you want them grouped under one error type rather than scattered across the error channel. A reason error is a tagged error with a reason field whose value is itself a union of tagged errors. The outer error names the operation; the inner reason names exactly what went wrong.

import { Effect, Schema } from "effect"
// Each reason is an ordinary tagged error...
class RateLimitError extends Schema.TaggedErrorClass<RateLimitError>()(
"RateLimitError",
{ retryAfter: Schema.Number }
) {}
class QuotaExceededError extends Schema.TaggedErrorClass<QuotaExceededError>()(
"QuotaExceededError",
{ limit: Schema.Number }
) {}
class SafetyBlockedError extends Schema.TaggedErrorClass<SafetyBlockedError>()(
"SafetyBlockedError",
{ category: Schema.String }
) {}
// ...and the outer error carries one of them in a `reason` field.
class AiError extends Schema.TaggedErrorClass<AiError>()("AiError", {
reason: Schema.Union([RateLimitError, QuotaExceededError, SafetyBlockedError])
}) {}
declare const callModel: Effect.Effect<string, AiError>

The error channel stays clean — callers see a single AiError — while the reason preserves the full detail for anyone who wants to drill in. This is the shape used throughout Effect’s own AI modules.

Effect.catchReason takes the outer error tag, the reason tag to handle, a handler for that reason, and an optional catch-all for the remaining reasons.

import { Effect } from "effect"
// AiError and its reason errors (RateLimitError, QuotaExceededError,
// SafetyBlockedError) are the ones declared in the first example above.
declare const callModel: Effect.Effect<string, AiError>
const handleOneReason = callModel.pipe(
Effect.catchReason(
"AiError", // the outer error _tag
"RateLimitError", // the reason _tag to catch
// handler for the caught reason — note `reason.retryAfter` is available
(reason) => Effect.succeed(`Retry after ${reason.retryAfter} seconds`),
// optional catch-all for the other reasons
(reason) => Effect.succeed(`Model call failed for reason: ${reason._tag}`)
)
)

Effect.catchReasons handles several reasons of one error at once, keyed by reason tag — the reason-level analogue of catchTags.

import { Effect } from "effect"
// AiError and its reason errors carry over from the first example above.
declare const callModel: Effect.Effect<string, AiError>
const handleMultipleReasons = callModel.pipe(
Effect.catchReasons("AiError", {
RateLimitError: (reason) =>
Effect.succeed(`Retry after ${reason.retryAfter} seconds`),
QuotaExceededError: (reason) =>
Effect.succeed(`Quota exceeded at ${reason.limit} tokens`)
// Any reasons you omit remain in the AiError that flows through.
})
)

As with catchReason, you can pass a final catch-all handler as the third argument to cover every remaining reason.

unwrapReason — promote reasons to the error channel

Section titled “unwrapReason — promote reasons to the error channel”

If you would rather treat each reason as a first-class error, Effect.unwrapReason flattens the reasons out of the wrapper and into the error channel. After unwrapping you can use the ordinary catchTags machinery.

import { Effect } from "effect"
// AiError and its reason errors carry over from the first example above.
declare const callModel: Effect.Effect<string, AiError>
const unwrapAndHandle = callModel.pipe(
// AiError disappears; RateLimitError | QuotaExceededError | SafetyBlockedError
// now flow directly in the error channel.
Effect.unwrapReason("AiError"),
Effect.catchTags({
RateLimitError: (reason) =>
Effect.succeed(`Back off for ${reason.retryAfter} seconds`),
QuotaExceededError: (reason) =>
Effect.succeed(`Increase quota beyond ${reason.limit}`),
SafetyBlockedError: (reason) =>
Effect.succeed(`Blocked by safety category: ${reason.category}`)
})
)
  • Build resilience around these errors with Fallback & Retry.
  • See reason errors in practice in the AI modules.