Defining Errors with Schema

Avatar of Hemanta SundarayHemanta Sundaray

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 can yield* 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.

  1. Call Schema.ErrorClass with an identifier string (this becomes the error’s .name property).
  2. Call the result with a fields object (or a Schema.Struct).

You then use the result as a base class with extends.

schema.ts
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); // true
console.log(err.name); // HttpError
console.log(err.statusCode); // 404
console.log(err.message); // Not Found

The 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:

schema.ts
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:

Terminal
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:

schema.ts
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); // 500
console.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:

  1. Call Schema.TaggedErrorClass with an optional identifier string. If you omit it, the tag value is used as the identifier.
  2. Call the result with a tag string and a fields object (or a Schema.Struct).
  3. Use the result as a base class with extends.
schema.ts
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); // NotFoundError
console.log(err.name); // NotFoundError
console.log(err.resource); // User
console.log(err.id); // abc-123

Default 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.

schema.ts
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 delay
const err1 = new RateLimitError({ endpoint: "/api/search" });
console.log(err1.endpoint); // "/api/search"
console.log(err1.retryAfterMs); // 60000
// Server told us to wait 5 seconds
const err2 = new RateLimitError({
endpoint: "/api/search",
retryAfterMs: 5000,
});
console.log(err2.retryAfterMs); // 5000

Zero-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 {}:

schema.ts
import { Schema } from "effect";
class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
"UnauthorizedError",
{},
) {}
// Both are valid
const 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:

schema.ts
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

FeatureErrorClassTaggedErrorClass
_tag fieldNot includedAutomatically added as a literal
IdentifierRequired (first argument)Optional (defaults to tag value)
Works with catchTagNo (no _tag)Yes
Constructor fieldsOnly your defined fieldsYour fields + _tag (auto-provided)
Base classYieldableErrorYieldableError

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.

schema.ts
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 input
Effect.runPromise(validateAge(25)).then(console.log);
// Running with invalid input and handling the error
const program = validateAge(-5).pipe(
Effect.catchTag("InvalidAgeError", (err) =>
Effect.succeed(`Rejected: ${err.reason} (got ${err.age})`),
),
);
Effect.runPromise(program).then(console.log);

Output:

Terminal
25
Rejected: 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.

schema.ts
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 object
const 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 instance
const decoded = decode({
_tag: "ValidationError",
field: "email",
expected: "a valid email address",
received: "not-an-email",
});
console.log(decoded instanceof ValidationError); // true
console.log(decoded instanceof Error); // true
console.log(decoded._tag); // "ValidationError"
console.log(decoded.field); // "email"

Output:

Terminal
{
_tag: 'ValidationError',
field: 'email',
expected: 'a valid email address',
received: 'not-an-email'
}
true
true
ValidationError
email

When 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:

schema.ts
import { Schema } from "effect";
// Base error with common fields
class AppError extends Schema.ErrorClass<AppError>("AppError")({
message: Schema.String,
timestamp: Schema.Number,
}) {}
// Extended error with additional fields
class 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); // true
console.log(err instanceof DatabaseError); // true
console.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():

schema.ts
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 TaggedErrorClass
console.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:

schema.ts
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); // true

Custom 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:

schema.ts
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:

Terminal
{ 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.

Sign in to save progress

Stay in the loop

Get notified when new Effect Atom related content is published.