In most applications, errors are not just strings. They carry structured data: an HTTP status code, a missing field name, a failed validation path. If you are already using Effect Schema to validate your data, it makes sense to validate your errors the same way.
Effect Schema provides two APIs for defining error classes whose fields are validated against a schema: Schema.ErrorClass and Schema.TaggedErrorClass. These give you error classes that are:
- Validated at construction time. When you create an instance with
new, the fields you pass in are checked against the schema. If a field is invalid, you get a clear error immediately, not a silent bug downstream. - Yieldable inside
Effect.gen. You canyield*an error instance directly, and it is automatically treated as a failed Effect. - Serializable. Because they are built on schemas, these error classes can be encoded to plain objects and decoded back, which is useful for logging, network transport, or persistence.
- Extensible. You can create error hierarchies where a base error class is extended with additional fields.
Schema.ErrorClass
Schema.ErrorClass creates an error class with schema-validated fields. It follows a curried, two-step pattern:
The term “curried” means that instead of calling a function with all its arguments at once, you call it one step at a time. Each call returns a new function that accepts the next argument. So instead of f(a, b), you write f(a)(b). You will see this pattern frequently in Effect.
- Call
Schema.ErrorClasswith an identifier string (this becomes the error’s.nameproperty). - Call the result with a fields object (or a
Schema.Struct).
You then use the result as a base class with extends.
import { Schema } from "effect";
class HttpError extends Schema.ErrorClass<HttpError>("HttpError")({ statusCode: Schema.Number, message: Schema.String,}) {}
const err = new HttpError({ statusCode: 404, message: "Not Found" });
console.log(err instanceof Error); // trueconsole.log(err.name); // HttpErrorconsole.log(err.statusCode); // 404console.log(err.message); // Not FoundThe HttpError class behaves like a normal JavaScript Error (it has .name, .message, .stack), but its fields are validated. If you pass an invalid value, construction fails immediately:
import { Schema } from "effect";
class HttpError extends Schema.ErrorClass<HttpError>("HttpError")({ statusCode: Schema.Number, message: Schema.String,}) {}
const badInput: unknown = { statusCode: "oops", message: "Not Found" };
try { Schema.decodeUnknownSync(HttpError)(badInput);} catch (error) { if (error instanceof Error) { console.log(error.message); }}Output:
Expected number, got "oops" at ["statusCode"]You can also pass a Schema.Struct instead of a plain fields object. This is useful when you want to reuse an existing struct schema:
import { Schema } from "effect";
const ErrorFields = Schema.Struct({ statusCode: Schema.Number, message: Schema.String,});
class HttpError extends Schema.ErrorClass<HttpError>("HttpError")( ErrorFields,) {}
const err = new HttpError({ statusCode: 500, message: "Internal Server Error",});console.log(err.statusCode); // 500console.log(err.message); // "Internal Server Error"Schema.TaggedErrorClass
Schema.TaggedErrorClass builds on Schema.ErrorClass by automatically adding a _tag field to your error class. The _tag field is a literal string that acts as a discriminator, letting you match errors by tag in handlers like Effect.catchTag.
It follows a curried, three-step pattern:
- Call
Schema.TaggedErrorClasswith an optional identifier string. If you omit it, the tag value is used as the identifier. - Call the result with a tag string and a fields object (or a
Schema.Struct). - Use the result as a base class with
extends.
import { Schema } from "effect";
class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()( "NotFoundError", { resource: Schema.String, id: Schema.String },) {}
const err = new NotFoundError({ resource: "User", id: "abc-123",});
console.log(err._tag); // NotFoundErrorconsole.log(err.name); // NotFoundErrorconsole.log(err.resource); // Userconsole.log(err.id); // abc-123Default Value for Error Fields
You can provide default values for error fields using Schema.withConstructorDefault. This allows callers to omit certain fields when constructing an error, while still having them present on the instance.
import { Option, Schema } from "effect";
class RateLimitError extends Schema.TaggedErrorClass<RateLimitError>()( "RateLimitError", { endpoint: Schema.String, retryAfterMs: Schema.Number.pipe( Schema.optionalKey, Schema.withConstructorDefault(() => Option.some(60_000)), ), },) {}
// Using the default retry delayconst err1 = new RateLimitError({ endpoint: "/api/search" });console.log(err1.endpoint); // "/api/search"console.log(err1.retryAfterMs); // 60000
// Server told us to wait 5 secondsconst err2 = new RateLimitError({ endpoint: "/api/search", retryAfterMs: 5000,});console.log(err2.retryAfterMs); // 5000Zero-Field Tagged Errors
Sometimes an error needs no extra data beyond its tag. In this case, you can pass an empty fields object. The constructor argument becomes optional, so you can write new NotFoundError() without passing {}:
import { Schema } from "effect";
class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()( "UnauthorizedError", {},) {}
// Both are validconst a = new UnauthorizedError();const b = new UnauthorizedError({});
console.log(a._tag); // "UnauthorizedError"console.log(b._tag); // "UnauthorizedError"Custom Identifier
By default, the tag value is used as both the _tag and the error’s .name. If you need a different .name (for example, to namespace your errors), pass an identifier as the first argument:
import { Schema } from "effect";
class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>( "myapp/NotFoundError",)("NotFoundError", { resource: Schema.String,}) {}
const err = new NotFoundError({ resource: "Invoice" });
console.log(err._tag); // "NotFoundError"console.log(err.name); // "myapp/NotFoundError"The _tag remains "NotFoundError" (used for matching with Effect.catchTag), while .name is "myapp/NotFoundError" (shown in stack traces and String(err)).
ErrorClass vs TaggedErrorClass
| Feature | ErrorClass | TaggedErrorClass |
|---|---|---|
_tag field | Not included | Automatically added as a literal |
| Identifier | Required (first argument) | Optional (defaults to tag value) |
| Works with catchTag | No (no _tag) | Yes |
| Constructor fields | Only your defined fields | Your fields + _tag (auto-provided) |
| Base class | YieldableError | YieldableError |
In practice, TaggedErrorClass is the more common choice because Effect’s error-handling combinators (Effect.catchTag, Effect.catchTags) rely on the _tag field to discriminate between error types.
Key Features
Yieldable Errors
Both ErrorClass and TaggedErrorClass produce classes that extend YieldableError. This means you can yield* an error instance directly inside an Effect.gen generator. When you do, the generator short-circuits and the enclosing Effect fails with that error.
import { Effect, Schema } from "effect";
class InvalidAgeError extends Schema.TaggedErrorClass<InvalidAgeError>()( "InvalidAgeError", { age: Schema.Number, reason: Schema.String, },) {}
function validateAge(age: number) { return Effect.gen(function* () { if (age < 0) { // yield* directly on the error instance yield* new InvalidAgeError({ age, reason: "Age cannot be negative", }); }
if (age > 150) { yield* new InvalidAgeError({ age, reason: "Age exceeds reasonable maximum", }); }
return age; });}
// Running with valid inputEffect.runPromise(validateAge(25)).then(console.log);
// Running with invalid input and handling the errorconst program = validateAge(-5).pipe( Effect.catchTag("InvalidAgeError", (err) => Effect.succeed(`Rejected: ${err.reason} (got ${err.age})`), ),);
Effect.runPromise(program).then(console.log);Output:
25Rejected: Age cannot be negative (got -5)Notice how Effect.catchTag("InvalidAgeError", ...) works seamlessly because InvalidAgeError has a _tag of "InvalidAgeError". The handler receives the fully typed error instance with access to .age and .reason.
Serializable
Because these error classes are built on Schema, they participate fully in the encoding and decoding pipeline. You can encode an error instance to a plain object and decode a plain object back into an error instance. This is useful when you need to serialize errors for logging, send them over the network, or store them.
import { Schema } from "effect";
class ValidationError extends Schema.TaggedErrorClass<ValidationError>()( "ValidationError", { field: Schema.String, expected: Schema.String, received: Schema.String, },) {}
const encode = Schema.encodeSync(ValidationError);const decode = Schema.decodeUnknownSync(ValidationError);
// Encoding: error instance -> plain objectconst err = new ValidationError({ field: "email", expected: "a valid email address", received: "not-an-email",});
const encoded = encode(err);console.log(encoded);
// Decoding: plain object -> error instanceconst decoded = decode({ _tag: "ValidationError", field: "email", expected: "a valid email address", received: "not-an-email",});
console.log(decoded instanceof ValidationError); // trueconsole.log(decoded instanceof Error); // trueconsole.log(decoded._tag); // "ValidationError"console.log(decoded.field); // "email"Output:
{ _tag: 'ValidationError', field: 'email', expected: 'a valid email address', received: 'not-an-email'}truetrueValidationErroremailWhen decoding, the _tag field must be present in the input and must match the expected literal value. The decoded result is a real instance of the error class, complete with .stack, instanceof checks, and yieldability.
Extending Error Classes
Both ErrorClass and TaggedErrorClass support inheritance through the .extend() method. This allows you to create error hierarchies where a base error carries common fields and derived errors add specialized ones.
The .extend() method takes a new identifier and returns a function that accepts the additional fields:
import { Schema } from "effect";
// Base error with common fieldsclass AppError extends Schema.ErrorClass<AppError>("AppError")({ message: Schema.String, timestamp: Schema.Number,}) {}
// Extended error with additional fieldsclass DatabaseError extends AppError.extend<DatabaseError>("DatabaseError")({ query: Schema.String, table: Schema.String,}) {}
const err = new DatabaseError({ message: "Insert failed", timestamp: Date.now(), query: "INSERT INTO orders ...", table: "orders",});
console.log(err instanceof AppError); // trueconsole.log(err instanceof DatabaseError); // trueconsole.log(err.message); // "Insert failed"console.log(err.table); // "orders"The extended class inherits all fields from the parent and adds its own. Instances of the extended class pass instanceof checks for both the parent and the child.
When you extend a TaggedErrorClass, the child class inherits the parent’s _tag value. The _tag is set by the root TaggedErrorClass, not overridden by .extend():
import { Schema } from "effect";
class BaseError extends Schema.TaggedErrorClass<BaseError>()("BaseError", { message: Schema.String,}) {}
class DetailedError extends BaseError.extend<DetailedError>("DetailedError")({ details: Schema.String,}) {}
const err = new DetailedError({ message: "Something failed", details: "Connection refused on port 5432",});
// _tag comes from the root TaggedErrorClassconsole.log(err._tag); // "BaseError"Because of this behavior, if you need each error to have its own distinct _tag for use with Effect.catchTag, define them as separate TaggedErrorClass declarations rather than using .extend().
Adding Custom Methods and Computed Properties
Since the schema class is a regular JavaScript class, you can add custom methods and properties to it:
import { Schema } from "effect";
class PaymentError extends Schema.TaggedErrorClass<PaymentError>()( "PaymentError", { amount: Schema.Number, currency: Schema.String, reason: Schema.String, },) { get displayAmount(): string { return `${this.currency} ${this.amount.toFixed(2)}`; }
get isRetryable(): boolean { return this.reason === "timeout" || this.reason === "rate_limit"; }}
const err = new PaymentError({ amount: 49.99, currency: "USD", reason: "timeout",});
console.log(err.displayAmount); // "USD 49.99"console.log(err.isRetryable); // trueCustom methods and getters are regular class members. They have full access to the validated fields and can provide derived values that make error handling more convenient.
Using Schema-Backed Errors with Effect.catchTag
The primary reason to use TaggedErrorClass is seamless integration with Effect’s tag-based error handling. Here is a more complete example showing how multiple tagged errors flow through an Effect pipeline:
import { Effect, Schema } from "effect";
// --- Error Definitions ---class NetworkError extends Schema.TaggedErrorClass<NetworkError>()( "NetworkError", { url: Schema.String, statusCode: Schema.Number, },) {}
class ParseError extends Schema.TaggedErrorClass<ParseError>()("ParseError", { rawBody: Schema.String, reason: Schema.String,}) {}
class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()( "NotFoundError", { resource: Schema.String, id: Schema.String, },) {}
// --- Business Logic ---function fetchUser(id: string) { return Effect.gen(function* () { // Simulate a network call that might fail if (id === "bad-network") { yield* new NetworkError({ url: `/api/users/${id}`, statusCode: 503, }); }
if (id === "bad-json") { yield* new ParseError({ rawBody: "{invalid json", reason: "Unexpected token at position 1", }); }
if (id === "unknown") { yield* new NotFoundError({ resource: "User", id, }); }
return { name: "Rob" }; });}
// --- Error Handling ---const program = fetchUser("unknown").pipe( Effect.catchTag("NotFoundError", (err) => Effect.succeed({ name: `Guest (${err.resource} ${err.id} not found)` }), ), Effect.catchTag("NetworkError", (err) => Effect.fail( new Error(`Network failure: ${err.statusCode} from ${err.url}`), ), ), Effect.catchTag("ParseError", (err) => Effect.fail(new Error(`Could not parse response: ${err.reason}`)), ),);
Effect.runPromise(program).then(console.log);Output:
{ name: 'Guest (User unknown not found)' }Each Effect.catchTag handler receives the specific error type, fully typed. Inside the "NotFoundError" handler, TypeScript knows that err has .resource and .id. Inside the "NetworkError" handler, it knows about .url and .statusCode. This is all powered by the _tag discriminator that TaggedErrorClass provides automatically.