EFFECT TAGGEDERROR: DEFINING FAILURE UPFRONT
How explicit error types reversed my thinking
Still learning Effect. But TaggedError—defining how a program can fail upfront—flipped my approach to error handling. This is the tip of the iceberg that got me to dig deeper.
The Problem With Traditional Error Handling
- - Try/catch everywhere. Errors are hidden, discovered at runtime.
- - Unknown failure modes. What can go wrong? Read the code. Hope you find all the paths.
- - Errors as afterthoughts. Write success path first, handle errors later (maybe).
- - Runtime surprises. Production errors you never considered. TypeScript can't help.
What Is TaggedError?
Effect provides two ways to create tagged errors: Schema.TaggedError (with validation) and Data.TaggedError (simpler). We'll use Schema.TaggedError for runtime validation. The _tag field enables discriminated unions. Works with Effect's type system.
// Define an error type upfront
import { Schema, Effect } from "effect";
class ValidationError extends Schema.TaggedError<ValidationError>()(
"ValidationError",
{
field: Schema.String,
message: Schema.String
}
) {}
// Now your function signature shows what can fail
const validateEmail = (email: string): Effect.Effect<string, ValidationError> =>
Effect.gen(function* () {
if (!email.includes("@")) {
return yield* Effect.fail(
new ValidationError({ field: "email", message: "Invalid email format" })
);
}
return email;
});The function signature Effect<string, ValidationError> tells you: this returns a string, or fails with ValidationError. No surprises.
The Mental Shift
Errors In Type Signatures
Effect's Effect<A, E, R> makes errors explicit:
A= success valueE= error type (union of TaggedErrors)R= requirements (dependencies)
You can't ignore errors. They're part of the contract. TypeScript forces you to handle them.
Think About Failure First
Before writing the success path, define what can fail. This changes how you design functions.
"What errors can this function produce?" becomes a design question, not an afterthought.
Pattern Matching
No more nested try/catch. Use Match or Effect.match to handle errors declaratively.
Compile-Time Guarantees
TypeScript knows all possible errors. Exhaustive pattern matching catches unhandled cases. No runtime surprises.
Before vs After
// BEFORE: Unknown failure modes
async function fetchUser(id: string) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error("Failed");
return await response.json();
} catch (error) {
// What errors can happen? Who knows.
throw error;
}
}
// AFTER: Explicit failure modes
import { Schema, Effect } from "effect";
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
"UserNotFound",
{ userId: Schema.String }
) {}
class NetworkError extends Schema.TaggedError<NetworkError>()(
"NetworkError",
{ cause: Schema.optional(Schema.Defect) }
) {}
const fetchUser = (id: string): Effect.Effect<User, UserNotFound | NetworkError> =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise({
try: () => fetch(`/api/users/${id}`),
catch: (error) => new NetworkError({ cause: error as Error })
});
if (response.status === 404) {
return yield* Effect.fail(new UserNotFound({ userId: id }));
}
return yield* Effect.tryPromise({
try: () => response.json(),
catch: (error) => new NetworkError({ cause: error as Error })
});
});The "after" version shows exactly what can fail. Callers know what to handle. TypeScript enforces it.
Pattern Matching On Errors
// Pattern matching on errors
import { Schema, Effect, Match } from "effect";
class NotFound extends Schema.TaggedError<NotFound>()(
"NotFound",
{ resource: Schema.String }
) {}
class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{ reason: Schema.String }
) {}
const handleError = (error: NotFound | Unauthorized) =>
Match.value(error).pipe(
Match.when({ _tag: "NotFound" }, ({ resource }) =>
`Resource ${resource} not found`
),
Match.when({ _tag: "Unauthorized" }, ({ reason }) =>
`Unauthorized: ${reason}`
),
Match.exhaustive
);
// Or with Effect.match
const program = Effect.gen(function* () {
// ... some operation that can fail
}).pipe(
Effect.match({
onFailure: handleError,
onSuccess: (value) => `Success: ${value}`
})
);Discriminated unions make pattern matching elegant. TypeScript ensures exhaustiveness.
Composing Multiple Error Types
// Compose multiple error types
import { Schema, Effect } from "effect";
class ValidationError extends Schema.TaggedError<ValidationError>()(
"ValidationError",
{ field: Schema.String, message: Schema.String }
) {}
class DatabaseError extends Schema.TaggedError<DatabaseError>()(
"DatabaseError",
{ query: Schema.String, cause: Schema.optional(Schema.Defect) }
) {}
class RateLimitError extends Schema.TaggedError<RateLimitError>()(
"RateLimitError",
{ retryAfter: Schema.Number }
) {}
// Function signature shows ALL possible failures
const createUser = (
email: string,
name: string
): Effect.Effect<User, ValidationError | DatabaseError | RateLimitError> =>
Effect.gen(function* () {
// Validation can fail
if (!email.includes("@")) {
return yield* Effect.fail(
new ValidationError({ field: "email", message: "Invalid email" })
);
}
// Database can fail
const user = yield* Effect.tryPromise({
try: () => db.users.create({ email, name }),
catch: (error) => new DatabaseError({
query: "INSERT INTO users",
cause: error as Error
})
});
return user;
});Functions can fail in multiple ways. The type signature shows all possibilities. Union types compose naturally.
Why This Matters
- + Fewer production surprises. All failure modes are explicit. No hidden error paths.
- + Better error messages. TaggedError fields carry context. Structured error data.
- + Type-safe error handling. Compiler catches unhandled errors. Exhaustive matching enforced.
- + Documentation through types. Function signatures document failure modes. Self-documenting code.
- + Forces upfront thinking. Design failure modes before success paths. Better architecture.
Real-World Example
API endpoint with typed errors. All failure modes explicit. Pattern matching at the boundary.
// Real-world: API endpoint with typed errors
import { Schema, Effect } from "effect";
import { Hono } from "hono";
class InvalidInput extends Schema.TaggedError<InvalidInput>()(
"InvalidInput",
{ field: Schema.String, value: Schema.unknown }
) {}
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
"UserNotFound",
{ userId: Schema.String }
) {}
class DatabaseError extends Schema.TaggedError<DatabaseError>()(
"DatabaseError",
{ operation: Schema.String, cause: Schema.optional(Schema.Defect) }
) {}
const updateUser = (
userId: string,
data: { name?: string; email?: string }
): Effect.Effect<User, InvalidInput | UserNotFound | DatabaseError> =>
Effect.gen(function* () {
// Validate input
if (data.email && !data.email.includes("@")) {
return yield* Effect.fail(
new InvalidInput({ field: "email", value: data.email })
);
}
// Check user exists
const existing = yield* Effect.tryPromise({
try: () => db.users.findById(userId),
catch: (error) => new DatabaseError({
operation: "findById",
cause: error as Error
})
});
if (!existing) {
return yield* Effect.fail(new UserNotFound({ userId }));
}
// Update user
const updated = yield* Effect.tryPromise({
try: () => db.users.update(userId, data),
catch: (error) => new DatabaseError({
operation: "update",
cause: error as Error
})
});
return updated;
});
// In your Hono route
app.put("/users/:id", async (c) => {
const userId = c.req.param("id");
const body = await c.req.json();
const result = await Effect.runPromiseExit(
updateUser(userId, body)
);
if (result._tag === "Failure") {
const error = result.cause._tag === "Fail" ? result.cause.error : null;
if (error?._tag === "InvalidInput") {
return c.json({ error: error.message }, 400);
}
if (error?._tag === "UserNotFound") {
return c.json({ error: "User not found" }, 404);
}
return c.json({ error: "Internal error" }, 500);
}
return c.json(result.value);
});The "Perfect" TaggedError Usage
// 1. Define errors upfront - part of your API contract
// 2. Function signature shows what can fail
// 3. Pattern match on errors at call site
// 4. TypeScript enforces exhaustive handling
// 5. No runtime surprises - all errors are typed
// 6. Errors compose naturally via union types
// 7. Structured error data with typed fields
// 8. Self-documenting failure modes
The Iceberg Beneath
TaggedError is just the tip. Effect offers:
- • Effect.gen - async/await-like syntax for composable effects
- • Layers - Dependency injection with type safety
- • Streams - Backpressure-aware data processing
- • Retry/Timeout - Built-in resilience patterns
- • Schema - Runtime validation with type inference
TaggedError got me to bite. The rest of Effect is the meal. Start with explicit errors. The rest follows naturally.