Skip to content

Tagged Errors

Expected errors are just values, so you need a way to define them. In Effect v4 the idiomatic tool is Schema.TaggedErrorClass: it produces an error class that carries a string _tag discriminator, validates its fields with Schema, and can be yield*-ed directly inside Effect.gen.

import { Effect, Schema } from "effect"
// Each error is a class with a unique `_tag` and a set of schema-typed fields.
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()(
"UserNotFound",
{ id: Schema.Number }
) {}
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ action: Schema.String }
) {}
// A function that returns an Effect should be written with `Effect.fn`, not as
// a plain function that returns `Effect.gen(...)`.
const loadProfile = Effect.fn("loadProfile")(function* (id: number, admin: boolean) {
if (id <= 0) {
// Yielding an error fails the effect with that error in the E channel.
return yield* new UserNotFound({ id })
}
if (!admin) {
return yield* new Unauthorized({ action: "read-profile" })
}
return { id, name: "Ada Lovelace" }
})
// ┌─── the union of every error the function can produce
// ▼
// Effect<{ id: number; name: string }, UserNotFound | Unauthorized>
const program = loadProfile(42, true)

Schema.TaggedErrorClass<Self>()("Tag", fields) returns a class. Note the empty () after the type argument — that two-step call is what lets TypeScript infer the field types precisely.

  • The first string, "UserNotFound", becomes the value of the readonly _tag property. That tag is the discriminator used by Effect.catchTag, Effect.catchTags, and Effect.match.
  • The fields object describes the error’s payload using Schema. Here id must be a number; passing anything else is a type error at the construction site.
  • The resulting class is yieldable: yield* new UserNotFound({ id }) inside Effect.gen fails the effect with that error, no Effect.fail wrapper needed.

You can still use Effect.fail when composing with .pipe:

import { Effect, Schema } from "effect"
class ParseError extends Schema.TaggedErrorClass<ParseError>()("ParseError", {
input: Schema.String,
message: Schema.String
}) {}
const parsePort = (input: string) => {
const port = Number(input)
return Number.isNaN(port)
? Effect.fail(new ParseError({ input, message: "not a number" }))
: Effect.succeed(port)
}

The _tag field turns your errors into a discriminated union, exactly like the tagged unions you would design by hand. This is what makes recovery type-safe: when you handle "UserNotFound", TypeScript narrows the value to the UserNotFound class (so error.id is available) and removes it from the remaining error channel.

import { Effect, Schema } from "effect"
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()(
"UserNotFound",
{ id: Schema.Number }
) {}
declare const program: Effect.Effect<string, UserNotFound>
const handled = program.pipe(
// `error` is narrowed to UserNotFound here, so `error.id` is a number.
Effect.catchTag("UserNotFound", (error) =>
Effect.succeed(`No user with id ${error.id}`)
)
)
// handled: Effect<string, never> — the error has been eliminated from the type

Real errors often wrap an underlying failure. Use Schema.Defect for a field that may hold any value (a caught exception, a driver error, anything), preserving it without forcing it into a specific shape.

import { Effect, Schema } from "effect"
class DatabaseError extends Schema.TaggedErrorClass<DatabaseError>()(
"DatabaseError",
{
query: Schema.String,
// `Schema.Defect` accepts an unknown underlying cause.
cause: Schema.Defect
}
) {}
const runQuery = (query: string) =>
Effect.try({
try: (): Array<unknown> => {
throw new Error("connection refused")
},
// Capture whatever was thrown as the error's `cause`.
catch: (cause) => new DatabaseError({ query, cause })
})