Error Handling Patterns
Consistent approaches to raising, catching, and surfacing failures.
Overview
Error handling determines how a system behaves when things go wrong: what information is surfaced, where errors propagate, and how callers distinguish recoverable from fatal conditions. The two primary paradigms are exceptions (throw/catch) and explicit return values (Result/Either types). The key principle is that errors should be handled at the appropriate layer and never silently swallowed.
Origin
Structured exception handling was introduced in CLU (Barbara Liskov, 1970s) and popularised by Ada (1983) and C++ (1985). Java's checked exceptions (mandatory catch clauses) attempted to make error handling explicit but produced verbose code. Rust's Result<T, E> type (2015) made the functional approach mainstream. Go's multiple return values (if err != nil) adopted explicit error returns without generics.
Examples
Result type for explicit error handling in TypeScript
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });
type UserNotFoundError = { type: 'USER_NOT_FOUND'; userId: string };
type DatabaseError = { type: 'DATABASE_ERROR'; cause: Error };
async function findUser(
id: string
): Promise<Result<User, UserNotFoundError | DatabaseError>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) return err({ type: 'USER_NOT_FOUND', userId: id });
return ok(user);
} catch (cause) {
return err({ type: 'DATABASE_ERROR', cause: cause as Error });
}
}
// Caller is forced to handle both outcomes
const result = await findUser('usr-42');
if (!result.ok) {
if (result.error.type === 'USER_NOT_FOUND') return res.status(404).json({ ... });
throw result.error.cause; // re-throw infrastructure errors
}
const user = result.value;The Result type makes the error surface explicit in the function signature. TypeScript's discriminated union on ok: boolean narrows the type in each branch, so result.value is only accessible when ok is true.
Railway-oriented error handling in Ruby
require 'dry-monads'
require 'dry-matcher'
class CreateUserService
include Dry::Monads[:result]
def call(params)
user_attrs = yield validate(params)
user = yield persist(user_attrs)
yield send_welcome_email(user)
Success(user)
end
private
def validate(params)
result = UserContract.new.call(params)
result.success? ? Success(result.values.to_h) : Failure(result.errors.to_h)
end
def persist(attrs)
user = User.create(attrs)
user.persisted? ? Success(user) : Failure(user.errors.full_messages)
end
def send_welcome_email(user)
WelcomeMailer.deliver(user)
Success(user)
rescue StandardError => e
Failure("Email delivery failed: #{e.message}")
end
end
result = CreateUserService.new.call(params)
result.success? ? render_user(result.value!) : render_errors(result.failure)dry-monads' yield within a do block (not Ruby's Enumerator yield) short-circuits on Failure, threading the Result through the pipeline. This is the "railway-oriented programming" pattern from Scott Wlaschin (F# for Fun and Profit, 2014).
Use Cases
- 01Service layer operations that can fail in multiple expected ways (validation, network, database) where callers need to distinguish failure types
- 02Public library functions where throwing unexpected exceptions would surprise consumers; Result forces them to consider the failure path
- 03API endpoint handlers where the error handling layer maps domain errors to HTTP status codes
- 04Background job retry logic where transient errors (network timeouts) should be retried and permanent errors (invalid data) should be reported to a dead letter queue
When Not to Use
- //Do not use Result types for truly exceptional conditions (out of memory, assertion violations) where the program cannot sensibly continue; let these bubble as uncaught exceptions
- //Do not use exceptions for control flow in hot paths; exception construction in Java and Ruby involves capturing a stack trace, which is expensive
- //Do not catch and re-throw exceptions without adding information; re-throwing adds a stack frame without clarity and obscures the original cause
Technical Notes
- Error boundaries in React (componentDidCatch, introduced in React 16) catch rendering errors in the component tree and prevent the entire UI from crashing; they do not catch errors in event handlers or async code
- Sentry (v7+) and Datadog APM capture unhandled exceptions with full stack traces, breadcrumbs, and release context. Integrating these early prevents the "works on my machine" debugging cycle
- Node.js process.on("unhandledRejection") catches Promise rejections that have no attached .catch handler; missing this causes silent failure in production. In Node 15+ an unhandled rejection terminates the process by default
- Structured error types (classes extending Error with typed fields) are better than error code strings because TypeScript can discriminate on them and callers can instanceof-check without string comparison