Chapter 9: Error Handling with Effect
Traditional TypeScript error handling is broken. throw produces untyped errors. try/catch catches everything, including bugs you should not swallow. Promise rejections are unknown. Function signatures do not tell you what can go wrong.
Effect fixes all of this. Errors become values, tracked in the type system, handled explicitly, and composed predictably.
Two Categories of Errors
Effect distinguishes between two fundamentally different kinds of failures:
Expected Errors (Typed, Recoverable)
These are failures you anticipate as part of normal operation. They are tracked in the E channel of Effect<A, E, R>.
Examples:
- User not found
- Validation failed
- Permission denied
- Resource already exists
- Network timeout
You model these explicitly and handle them in your code.
Unexpected Errors (Defects, Unrecoverable)
These are bugs — things that should never happen in correct code. They are NOT tracked in the type system.
Examples:
- Null pointer dereference
- Array index out of bounds
- Division by zero
- Library bug
- Out of memory
You do not catch these in normal code. They propagate up and get logged by the runtime. If they happen, something is wrong with your code and you need to fix it.
// Expected error — part of the domain
yield* Effect.fail(new UserNotFoundError({ userId: "123" }));
// Defect — should never happen, indicates a bug
yield* Effect.die("This should be unreachable");
// Or just throw — thrown exceptions become defects
throw new Error("Bug: impossible state reached");
Defining Error Types
Use Data.TaggedError to create typed error classes:
import { Data } from "effect";
// Each error has a unique _tag for discrimination
export class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
readonly userId: string;
}> {}
export class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string;
readonly message: string;
}> {}
export class ProjectNameTakenError extends Data.TaggedError("ProjectNameTakenError")<{
readonly name: string;
readonly organizationId: string;
}> {}
export class InsufficientPermissionsError extends Data.TaggedError("InsufficientPermissionsError")<{
readonly userId: string;
readonly action: string;
readonly resource: string;
}> {}
export class DatabaseError extends Data.TaggedError("DatabaseError")<{
readonly cause: unknown;
}> {}
export class NetworkError extends Data.TaggedError("NetworkError")<{
readonly url: string;
readonly status?: number;
readonly cause: unknown;
}> {}
Why Tagged Errors
The _tag property enables discriminated union matching — TypeScript can narrow the error type based on the tag:
const result = yield* Effect.either(someOperation);
if (Either.isLeft(result)) {
const error = result.left;
switch (error._tag) {
case "UserNotFoundError":
// TypeScript knows: error.userId is available
console.log(`User ${error.userId} not found`);
break;
case "ValidationError":
// TypeScript knows: error.field and error.message are available
console.log(`Validation failed on ${error.field}: ${error.message}`);
break;
case "DatabaseError":
// TypeScript knows: error.cause is available
console.log(`Database error`, error.cause);
break;
}
}
Yieldable Errors
TaggedErrors can be yielded directly in Effect.gen — no need to wrap in Effect.fail:
const getUser = (id: string) =>
Effect.gen(function* () {
const user = yield* findUserById(id);
if (!user) {
// Can yield the error directly — no Effect.fail wrapper needed
return yield* new UserNotFoundError({ userId: id });
}
return user;
});
Error Handling Operators
catchAll — Catch Everything
const safe = someOperation.pipe(
Effect.catchAll((error) => {
// error is the full union of all possible errors
console.error("Operation failed:", error);
return Effect.succeed(fallbackValue);
})
);
// Error channel is now `never` — all errors are handled
catchTag — Catch Specific Error by Tag
This is the most common and recommended pattern:
const withFallback = getUser(userId).pipe(
Effect.catchTag("UserNotFoundError", (error) => {
// error is narrowed to UserNotFoundError
return Effect.succeed(createGuestUser());
})
);
// Type: Effect<User, DatabaseError, UserRepository>
// UserNotFoundError is removed from the error channel
// DatabaseError remains because it was not handled
catchTags — Catch Multiple Tags at Once
const handled = createProject(input).pipe(
Effect.catchTags({
ProjectNameTakenError: (error) =>
Effect.fail(new ValidationError({
field: "name",
message: `Project "${error.name}" already exists`,
})),
InsufficientPermissionsError: (error) =>
Effect.fail(new ValidationError({
field: "_form",
message: "You do not have permission to create projects",
})),
})
);
// Original errors are replaced with ValidationError
catchSome — Conditionally Catch
import { Option } from "effect";
const selective = someOperation.pipe(
Effect.catchSome((error) => {
if (error._tag === "NetworkError" && error.status === 404) {
return Option.some(Effect.succeed(null));
}
return Option.none(); // Don't handle other errors
})
);
catchIf — Catch with Predicate
const retryable = fetchData.pipe(
Effect.catchIf(
(error): error is NetworkError =>
error._tag === "NetworkError" && (error.status ?? 0) >= 500,
(error) => {
// Only catches 5xx network errors
return fetchFromFallback;
}
)
);
either — Convert to Discriminated Union
import { Either } from "effect";
const result = yield* Effect.either(getUser(userId));
// Type: Either<User, UserNotFoundError | DatabaseError>
if (Either.isRight(result)) {
const user = result.right; // Type: User
} else {
const error = result.left; // Type: UserNotFoundError | DatabaseError
}
This is useful when you want to handle both success and failure in the same code path, such as form submission.
Retry Patterns
Effect provides declarative retry logic through Schedule:
import { Effect, Schedule } from "effect";
// Retry 3 times with exponential backoff
const resilient = fetchData.pipe(
Effect.retry(
Schedule.exponential("1 second").pipe(
Schedule.compose(Schedule.recurs(3))
)
)
);
// Retry with jitter (prevents thundering herd)
const withJitter = fetchData.pipe(
Effect.retry(
Schedule.exponential("500 millis").pipe(
Schedule.jittered,
Schedule.compose(Schedule.recurs(5))
)
)
);
// Retry only specific errors
const selectiveRetry = fetchData.pipe(
Effect.retry({
schedule: Schedule.recurs(3),
while: (error) => error._tag === "NetworkError",
})
);
Mapping Errors Between Layers
When crossing architectural boundaries, map infrastructure errors to domain errors:
// Infrastructure layer returns DatabaseError
const findUser = (id: string) =>
Effect.tryPromise({
try: () => prisma.user.findUnique({ where: { id } }),
catch: (e) => new DatabaseError({ cause: e }),
});
// Application layer maps to domain error
const getUser = (id: string) =>
findUser(id).pipe(
Effect.flatMap((user) =>
user
? Effect.succeed(user)
: Effect.fail(new UserNotFoundError({ userId: id }))
),
// Map infrastructure errors to a more general error for consumers
Effect.mapError((error) => {
if (error._tag === "DatabaseError") {
return new ServiceUnavailableError({ cause: error });
}
return error;
})
);
React Error Boundaries Integration
At the UI level, combine Effect error handling with React error boundaries:
// shared/components/feedback/error-boundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to monitoring service
console.error("Error boundary caught:", error, errorInfo);
}
reset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
return typeof this.props.fallback === "function"
? this.props.fallback(this.state.error, this.reset)
: this.props.fallback;
}
return this.props.children;
}
}
Handling Mutation Errors in the UI
function CreateProjectForm() {
const createProject = useCreateProject();
const handleSubmit = (data: CreateProjectInput) => {
createProject.mutate(data, {
onError: (error) => {
// The error from Effect.runPromise is the typed error
if (error instanceof ProjectNameTakenError) {
form.setError("name", {
message: `"${error.name}" is already taken`,
});
} else if (error instanceof InsufficientPermissionsError) {
toast.error("You don't have permission to create projects");
} else {
// Unexpected error — show generic message
toast.error("Something went wrong. Please try again.");
}
},
});
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
</form>
);
}
Error Modeling Best Practices
1. One Error Class per Failure Mode
// ✅ Specific, actionable errors
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
readonly userId: string;
}> {}
class EmailAlreadyRegisteredError extends Data.TaggedError("EmailAlreadyRegisteredError")<{
readonly email: string;
}> {}
// ❌ Generic, uninformative errors
class AppError extends Data.TaggedError("AppError")<{
readonly message: string;
readonly code: string;
}> {}
2. Include Context in Errors
// ✅ Enough context to debug and display a good message
class ProjectNotFoundError extends Data.TaggedError("ProjectNotFoundError")<{
readonly projectId: string;
}> {}
// ❌ No context — useless for debugging or user messages
class NotFoundError extends Data.TaggedError("NotFoundError")<{}> {}
3. Organize Errors by Domain
features/projects/domain/errors/
├── project-errors.ts # ProjectNotFoundError, ProjectNameTakenError
├── task-errors.ts # TaskNotFoundError, TaskAlreadyCompletedError
└── common-errors.ts # InsufficientPermissionsError, ValidationError
4. Error Hierarchy for API Responses
Map domain errors to HTTP status codes at the API boundary:
const errorToHttpStatus = (error: AppError): number => {
switch (error._tag) {
case "UserNotFoundError":
case "ProjectNotFoundError":
case "TaskNotFoundError":
return 404;
case "ValidationError":
case "ProjectNameTakenError":
return 400;
case "InsufficientPermissionsError":
return 403;
case "AuthenticationError":
return 401;
case "DatabaseError":
case "ServiceUnavailableError":
return 500;
default:
return 500;
}
};
Summary
- ✅ Expected errors are tracked in the type system with
Data.TaggedError - ✅ Unexpected errors (defects) bypass the error channel — they indicate bugs
- ✅ Use
catchTagandcatchTagsfor precise, type-narrowing error handling - ✅ Use
eitherwhen you need both success and failure in the same code path - ✅ Use
Effect.retrywithSchedulefor resilient network operations - ✅ Map errors between layers — infrastructure errors → domain errors → UI messages
- ✅ Model one error class per failure mode with enough context to debug
- ✅ React error boundaries catch defects; Effect error handling catches expected errors