Error Handling

Typed errors with Effect — catch at boundaries, never lose context

Tagged Errors

Define domain errors as tagged classes. The type system tracks which errors can occur and ensures they're handled.

import { Data } from "effect"

class NotFoundError extends Data.TaggedError("NotFoundError")<{
  resource: string
  id: string
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  field: string
  message: string
}> {}

class UnauthorizedError extends Data.TaggedError("UnauthorizedError")<{
  reason: string
}> {}

Throwing Errors

const getUser = (id: string) =>
  Effect.gen(function* () {
    const repo = yield* UserRepo
    const user = yield* repo.findById(id)

    if (!user) {
      return yield* new NotFoundError({ resource: "User", id })
    }

    return user
  })

// Type: Effect<User, NotFoundError, UserRepo>

Catching by Tag

HttpRouter.get("/users/:id", Effect.gen(function* () {
  const { id } = yield* HttpRouter.schemaPathParams(S.Struct({ id: S.String }))
  const user = yield* getUser(id)
  return HttpServerResponse.json(user)
}).pipe(
  Effect.catchTag("NotFoundError", (e) =>
    HttpServerResponse.json(
      { error: `${e.resource} with id ${e.id} not found` },
      { status: 404 }
    )
  )
))

Catching Multiple Errors

import { Match } from "effect"

type AppError = NotFoundError | ValidationError | UnauthorizedError

const handleError = (error: AppError) =>
  Match.value(error).pipe(
    Match.tag("NotFoundError", (e) =>
      HttpServerResponse.json(
        { error: `${e.resource} not found` },
        { status: 404 }
      )
    ),
    Match.tag("ValidationError", (e) =>
      HttpServerResponse.json(
        { error: e.message, field: e.field },
        { status: 400 }
      )
    ),
    Match.tag("UnauthorizedError", (e) =>
      HttpServerResponse.json(
        { error: e.reason },
        { status: 401 }
      )
    ),
    Match.exhaustive
  )

// Apply to handler
handler.pipe(Effect.catchAll(handleError))

Global Error Handler

const withErrorHandler = <R>(
  handler: Effect.Effect<HttpServerResponse.HttpServerResponse, AppError, R>
) =>
  pipe(
    handler,
    Effect.catchAll(handleError),
    Effect.catchAllDefect((defect) => {
      // Log unexpected errors
      console.error("Unexpected error:", defect)
      return HttpServerResponse.json(
        { error: "Internal server error" },
        { status: 500 }
      )
    })
  )

// Wrap all route handlers
export const routes = [
  route.get("/users/:id", withErrorHandler(getUser)),
  route.post("/users", withErrorHandler(createUser)),
] as const

Error Context

// Add context to errors
const getUserWithContext = (id: string) =>
  getUser(id).pipe(
    Effect.mapError((e) =>
      e._tag === "NotFoundError"
        ? new NotFoundError({ ...e, resource: "User" })
        : e
    ),
    Effect.withSpan("getUser", { attributes: { userId: id } })
  )

Retry on Failure

import { Schedule } from "effect"

const fetchWithRetry = Effect.gen(function* () {
  const response = yield* Effect.tryPromise({
    try: () => fetch("https://api.example.com/data"),
    catch: () => new NetworkError({ url: "..." })
  })
  return yield* Effect.tryPromise(() => response.json())
}).pipe(
  Effect.retry(
    Schedule.exponential("100 millis").pipe(
      Schedule.compose(Schedule.recurs(3))
    )
  ),
  Effect.catchTag("NetworkError", () =>
    Effect.succeed({ fallback: true })
  )
)

Effect.catchIf

// Catch errors conditionally
handler.pipe(
  Effect.catchIf(
    (e): e is NotFoundError => e._tag === "NotFoundError" && e.resource === "User",
    () => HttpServerResponse.json({ error: "User not found" }, { status: 404 })
  )
)

Parse Errors

import { ArrayFormatter } from "@effect/schema"

HttpRouter.post("/users", Effect.gen(function* () {
  const body = yield* HttpServerRequest.schemaBodyJson(CreateUser)
  // ...
}).pipe(
  Effect.catchTag("ParseError", (e) => {
    const issues = ArrayFormatter.formatErrorSync(e)
    return HttpServerResponse.json(
      {
        error: "Validation failed",
        issues: issues.map(i => ({
          path: i.path.join("."),
          message: i.message
        }))
      },
      { status: 400 }
    )
  })
))