Error Handling as an Architecture Concern, Not an Afterthought
Error Handling as an Architecture Concern, Not an Afterthought
Designing error boundaries strategically, typed errors with discriminated unions in TypeScript, propagating errors across async boundaries cleanly, and building a consistent error language across your entire stack.
The Architectural Failure of Ad-Hoc Error Handling
Most codebases treat error handling as a local concern—sprinkle try/catch where things might fail, console.error the problem, maybe show a toast. This approach creates several systemic failures:
- Inconsistent error shapes - Every function invents its own error format
- Lost context - Stack traces don't capture business context
- Silent failures - Errors caught and swallowed without proper handling
- Unclear recovery paths - Code doesn't express what recovery is possible
- Debugging nightmares - Correlation across async boundaries is impossible
Error handling is a cross-cutting architectural concern. It needs the same deliberate design as your data model, API contracts, or state management.
┌─────────────────────────────────────────────────────────────────────┐
│ TYPICAL ERROR "ARCHITECTURE" │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Component A Service B Repository C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ try { │ │ try { │ │ try { │ │
│ │ ... │ ??? │ ... │ ??? │ ... │ │
│ │ } catch │ ──────► │ } catch │ ──────► │ } catch │ │
│ │ ??? │ │ ??? │ │ ??? │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ console.error throw new Error return null │
│ (lost) (untyped) (silent failure) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Typed Errors with Discriminated Unions
TypeScript's type system can encode error possibilities into function signatures. The compiler then forces callers to handle each case.
The Error Type Taxonomy
// errors/types.ts
/**
* Base error interface - all domain errors extend this
*/
interface BaseError {
readonly _tag: string;
readonly message: string;
readonly timestamp: number;
readonly correlationId?: string;
}
/**
* Errors that indicate invalid input from users/callers
* Recovery: Fix the input and retry
*/
export interface ValidationError extends BaseError {
readonly _tag: 'ValidationError';
readonly field: string;
readonly value: unknown;
readonly constraint: string;
}
/**
* Errors from external systems (APIs, databases, etc.)
* Recovery: Retry with backoff, fallback to cache, degrade gracefully
*/
export interface NetworkError extends BaseError {
readonly _tag: 'NetworkError';
readonly url: string;
readonly statusCode?: number;
readonly retryable: boolean;
readonly cause?: Error;
}
/**
* Resource doesn't exist
* Recovery: Show not-found UI, redirect, suggest alternatives
*/
export interface NotFoundError extends BaseError {
readonly _tag: 'NotFoundError';
readonly resourceType: string;
readonly resourceId: string;
}
/**
* User lacks permission
* Recovery: Show auth prompt, redirect to login, request access
*/
export interface AuthorizationError extends BaseError {
readonly _tag: 'AuthorizationError';
readonly requiredPermission: string;
readonly userId?: string;
}
/**
* Business rule violation
* Recovery: Depends on the rule - show explanation, suggest alternatives
*/
export interface BusinessRuleError extends BaseError {
readonly _tag: 'BusinessRuleError';
readonly rule: string;
readonly context: Record<string, unknown>;
}
/**
* Unexpected system failure
* Recovery: Log, alert, show generic error, potentially retry
*/
export interface SystemError extends BaseError {
readonly _tag: 'SystemError';
readonly cause?: Error;
readonly stack?: string;
}
// Union of all domain errors
export type DomainError =
| ValidationError
| NetworkError
| NotFoundError
| AuthorizationError
| BusinessRuleError
| SystemError;
// Type guard utilities
export const isValidationError = (e: DomainError): e is ValidationError =>
e._tag === 'ValidationError';
export const isRetryable = (e: DomainError): boolean => {
switch (e._tag) {
case 'NetworkError':
return e.retryable;
case 'SystemError':
return true; // System errors often transient
default:
return false;
}
};
Error Constructors
// errors/constructors.ts
import { v4 as uuid } from 'uuid';
import type {
ValidationError,
NetworkError,
NotFoundError,
AuthorizationError,
BusinessRuleError,
SystemError,
} from './types';
const createBaseError = (correlationId?: string) => ({
timestamp: Date.now(),
correlationId: correlationId ?? uuid(),
});
export const Errors = {
validation(
field: string,
value: unknown,
constraint: string,
correlationId?: string
): ValidationError {
return {
...createBaseError(correlationId),
_tag: 'ValidationError',
message: `Validation failed for ${field}: ${constraint}`,
field,
value,
constraint,
};
},
network(
url: string,
opts: {
statusCode?: number;
message?: string;
retryable?: boolean;
cause?: Error;
} = {},
correlationId?: string
): NetworkError {
const retryable = opts.retryable ?? (
opts.statusCode === undefined ||
opts.statusCode >= 500 ||
opts.statusCode === 429
);
return {
...createBaseError(correlationId),
_tag: 'NetworkError',
message: opts.message ?? `Network request failed: ${url}`,
url,
statusCode: opts.statusCode,
retryable,
cause: opts.cause,
};
},
notFound(
resourceType: string,
resourceId: string,
correlationId?: string
): NotFoundError {
return {
...createBaseError(correlationId),
_tag: 'NotFoundError',
message: `${resourceType} not found: ${resourceId}`,
resourceType,
resourceId,
};
},
authorization(
requiredPermission: string,
userId?: string,
correlationId?: string
): AuthorizationError {
return {
...createBaseError(correlationId),
_tag: 'AuthorizationError',
message: `Missing permission: ${requiredPermission}`,
requiredPermission,
userId,
};
},
businessRule(
rule: string,
context: Record<string, unknown>,
message?: string,
correlationId?: string
): BusinessRuleError {
return {
...createBaseError(correlationId),
_tag: 'BusinessRuleError',
message: message ?? `Business rule violated: ${rule}`,
rule,
context,
};
},
system(cause: unknown, correlationId?: string): SystemError {
const error = cause instanceof Error ? cause : new Error(String(cause));
return {
...createBaseError(correlationId),
_tag: 'SystemError',
message: error.message,
cause: error,
stack: error.stack,
};
},
} as const;
The Result Type Pattern
Throwing exceptions breaks type safety—the compiler can't track what a function might throw. The Result pattern makes errors values that flow through your type system.
// lib/result.ts
/**
* Represents either success (Ok) or failure (Err)
* Inspired by Rust's Result<T, E>
*/
export type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export const Result = {
ok<T>(value: T): Result<T, never> {
return { ok: true, value };
},
err<E>(error: E): Result<never, E> {
return { ok: false, error };
},
/**
* Wraps a throwing function into a Result
*/
fromThrowable<T, E>(
fn: () => T,
errorMapper: (e: unknown) => E
): Result<T, E> {
try {
return Result.ok(fn());
} catch (e) {
return Result.err(errorMapper(e));
}
},
/**
* Wraps an async throwing function into a Result
*/
async fromPromise<T, E>(
promise: Promise<T>,
errorMapper: (e: unknown) => E
): Promise<Result<T, E>> {
try {
return Result.ok(await promise);
} catch (e) {
return Result.err(errorMapper(e));
}
},
/**
* Maps the success value
*/
map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? Result.ok(fn(result.value)) : result;
},
/**
* Maps the error value
*/
mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
return result.ok ? result : Result.err(fn(result.error));
},
/**
* Chains Result-returning functions
*/
flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
},
/**
* Combines multiple Results - fails fast on first error
*/
all<T extends readonly Result<unknown, unknown>[]>(
results: T
): Result<
{ [K in keyof T]: T[K] extends Result<infer U, unknown> ? U : never },
T[number] extends Result<unknown, infer E> ? E : never
> {
const values: unknown[] = [];
for (const result of results) {
if (!result.ok) return result as any;
values.push(result.value);
}
return Result.ok(values) as any;
},
/**
* Unwraps or throws - use at boundaries only
*/
unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value;
throw result.error;
},
/**
* Unwraps with default value
*/
unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
return result.ok ? result.value : defaultValue;
},
/**
* Pattern match on Result
*/
match<T, E, U>(
result: Result<T, E>,
handlers: {
ok: (value: T) => U;
err: (error: E) => U;
}
): U {
return result.ok ? handlers.ok(result.value) : handlers.err(result.error);
},
} as const;
Using Result in Practice
// services/userService.ts
import { Result } from '../lib/result';
import { Errors, type DomainError, type NotFoundError, type ValidationError } from '../errors';
import type { User, CreateUserInput } from '../types';
type CreateUserError = ValidationError | DomainError;
type GetUserError = NotFoundError | NetworkError;
export const UserService = {
async create(
input: CreateUserInput
): Promise<Result<User, CreateUserError>> {
// Validation
if (!input.email.includes('@')) {
return Result.err(
Errors.validation('email', input.email, 'must be valid email')
);
}
if (input.password.length < 8) {
return Result.err(
Errors.validation('password', '[redacted]', 'must be at least 8 characters')
);
}
// Check uniqueness
const existing = await db.user.findUnique({ where: { email: input.email } });
if (existing) {
return Result.err(
Errors.businessRule(
'unique_email',
{ email: input.email },
'Email already registered'
)
);
}
// Create user
const createResult = await Result.fromPromise(
db.user.create({ data: input }),
(e) => Errors.system(e)
);
return createResult;
},
async getById(id: string): Promise<Result<User, GetUserError>> {
const result = await Result.fromPromise(
db.user.findUnique({ where: { id } }),
(e) => Errors.system(e)
);
if (!result.ok) return result;
if (!result.value) {
return Result.err(Errors.notFound('User', id));
}
return Result.ok(result.value);
},
async updateEmail(
userId: string,
newEmail: string
): Promise<Result<User, ValidationError | NotFoundError | BusinessRuleError>> {
// Chain multiple operations with flatMap
const userResult = await this.getById(userId);
return Result.flatMap(userResult, async (user) => {
if (!newEmail.includes('@')) {
return Result.err(
Errors.validation('email', newEmail, 'must be valid email')
);
}
if (user.email === newEmail) {
return Result.err(
Errors.businessRule(
'email_unchanged',
{ current: user.email, new: newEmail },
'New email must be different from current'
)
);
}
return Result.fromPromise(
db.user.update({ where: { id: userId }, data: { email: newEmail } }),
(e) => Errors.system(e)
);
});
},
};
Consuming Results in Components
// components/CreateUserForm.tsx
import { useState } from 'react';
import { Result } from '../lib/result';
import { UserService } from '../services/userService';
import type { DomainError } from '../errors';
export function CreateUserForm() {
const [error, setError] = useState<DomainError | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleSubmit = async (data: FormData) => {
setError(null);
setFieldErrors({});
const result = await UserService.create({
email: data.get('email') as string,
password: data.get('password') as string,
});
Result.match(result, {
ok: (user) => {
router.push(`/users/${user.id}`);
},
err: (error) => {
// Exhaustive handling based on error type
switch (error._tag) {
case 'ValidationError':
setFieldErrors({ [error.field]: error.constraint });
break;
case 'BusinessRuleError':
if (error.rule === 'unique_email') {
setFieldErrors({ email: 'This email is already registered' });
} else {
setError(error);
}
break;
case 'NetworkError':
if (error.retryable) {
toast.error('Connection issue. Please try again.');
} else {
setError(error);
}
break;
case 'SystemError':
// Log and show generic message
logger.error('User creation failed', { error });
toast.error('Something went wrong. Please try again later.');
break;
default:
// TypeScript ensures this is never reached if all cases handled
const _exhaustive: never = error;
}
},
});
};
return (
<form onSubmit={handleSubmit}>
<input name="email" />
{fieldErrors.email && <span className="error">{fieldErrors.email}</span>}
<input name="password" type="password" />
{fieldErrors.password && <span className="error">{fieldErrors.password}</span>}
<button type="submit">Create Account</button>
</form>
);
}
Error Propagation Across Async Boundaries
The Correlation ID Pattern
Every error needs context for debugging. A correlation ID threads through the entire request lifecycle:
// lib/context.ts
import { AsyncLocalStorage } from 'async_hooks';
interface RequestContext {
correlationId: string;
userId?: string;
traceId?: string;
spanId?: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getCorrelationId(): string {
return requestContext.getStore()?.correlationId ?? 'no-context';
}
export function withContext<T>(
context: RequestContext,
fn: () => T
): T {
return requestContext.run(context, fn);
}
// Middleware that establishes context
export function contextMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const correlationId = req.headers['x-correlation-id'] as string ?? uuid();
const traceId = req.headers['x-trace-id'] as string;
withContext(
{
correlationId,
traceId,
userId: req.user?.id,
},
() => {
res.setHeader('x-correlation-id', correlationId);
next();
}
);
}
Enriching Errors with Context
// lib/errorEnricher.ts
import { getCorrelationId } from './context';
import type { DomainError } from '../errors';
export function enrichError<E extends DomainError>(error: E): E {
return {
...error,
correlationId: error.correlationId ?? getCorrelationId(),
};
}
// Wrapper for service methods
export function withErrorContext<T extends (...args: any[]) => Promise<Result<any, DomainError>>>(
fn: T
): T {
return (async (...args: Parameters<T>) => {
const result = await fn(...args);
if (!result.ok) {
return Result.err(enrichError(result.error));
}
return result;
}) as T;
}
Cross-Service Error Serialization
When errors cross service boundaries (HTTP, queues, etc.), they need serialization:
// errors/serialization.ts
import type { DomainError } from './types';
interface SerializedError {
_tag: string;
message: string;
timestamp: number;
correlationId?: string;
details: Record<string, unknown>;
}
export function serializeError(error: DomainError): SerializedError {
const { _tag, message, timestamp, correlationId, ...details } = error;
// Strip non-serializable fields
const cleanDetails = Object.fromEntries(
Object.entries(details).filter(([_, v]) => {
try {
JSON.stringify(v);
return true;
} catch {
return false;
}
})
);
return {
_tag,
message,
timestamp,
correlationId,
details: cleanDetails,
};
}
export function deserializeError(serialized: SerializedError): DomainError {
const { _tag, message, timestamp, correlationId, details } = serialized;
// Reconstruct the specific error type
return {
_tag,
message,
timestamp,
correlationId,
...details,
} as DomainError;
}
// HTTP response formatting
export function toHttpError(error: DomainError): {
status: number;
body: { error: SerializedError };
} {
const status = (() => {
switch (error._tag) {
case 'ValidationError':
return 400;
case 'AuthorizationError':
return 403;
case 'NotFoundError':
return 404;
case 'BusinessRuleError':
return 422;
case 'NetworkError':
return 502;
case 'SystemError':
return 500;
default:
return 500;
}
})();
return {
status,
body: { error: serializeError(error) },
};
}
Strategic Error Boundaries in React
Error boundaries aren't just "catch all React errors." They're architectural components that define failure isolation zones.
Boundary Placement Strategy
┌─────────────────────────────────────────────────────────────────────┐
│ App Shell │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Root Error Boundary │ │
│ │ - Catches catastrophic failures │ │
│ │ - Shows "something went wrong" with refresh option │ │
│ │ - Reports to error tracking (Sentry, etc.) │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐│ │
│ │ │ Navigation │ │ Main Content ││ │
│ │ │ (no boundary - │ │ ┌────────────────────────────┐ ││ │
│ │ │ failures should │ │ │ Route Error Boundary │ ││ │
│ │ │ propagate up) │ │ │ - Per-route isolation │ ││ │
│ │ │ │ │ │ - "Page failed to load" │ ││ │
│ │ │ │ │ │ │ ││ │
│ │ │ │ │ │ ┌──────────┐ ┌──────────┐ │ ││ │
│ │ │ │ │ │ │ Widget A │ │ Widget B │ │ ││ │
│ │ │ │ │ │ │ Boundary │ │ Boundary │ │ ││ │
│ │ │ │ │ │ │ │ │ │ │ ││ │
│ │ │ │ │ │ │ Isolated │ │ Isolated │ │ ││ │
│ │ │ │ │ │ │ failures │ │ failures │ │ ││ │
│ │ │ │ │ │ └──────────┘ └──────────┘ │ ││ │
│ │ │ │ │ └────────────────────────────┘ ││ │
│ │ └─────────────────────┘ └─────────────────────────────────┘│ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Production Error Boundary Implementation
// components/ErrorBoundary.tsx
import {
Component,
type ReactNode,
type ErrorInfo,
createContext,
useContext,
} from 'react';
import { serializeError, Errors, type DomainError } from '../errors';
interface ErrorBoundaryContextValue {
error: DomainError | null;
reset: () => void;
reportError: (error: unknown, context?: Record<string, unknown>) => void;
}
const ErrorBoundaryContext = createContext<ErrorBoundaryContextValue | null>(null);
export function useErrorBoundary() {
const context = useContext(ErrorBoundaryContext);
if (!context) {
throw new Error('useErrorBoundary must be used within an ErrorBoundary');
}
return context;
}
interface Props {
children: ReactNode;
fallback: (props: { error: DomainError; reset: () => void }) => ReactNode;
onError?: (error: DomainError, errorInfo: ErrorInfo) => void;
isolationLevel: 'root' | 'route' | 'widget';
}
interface State {
error: DomainError | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: unknown): State {
// Convert any error to our DomainError type
const domainError = error instanceof Error && '_tag' in error
? error as unknown as DomainError
: Errors.system(error);
return { error: domainError };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const domainError = this.state.error!;
// Structured logging with context
console.error('[ErrorBoundary]', {
isolationLevel: this.props.isolationLevel,
error: serializeError(domainError),
componentStack: errorInfo.componentStack,
});
// Report to error tracking
this.reportToService(domainError, errorInfo);
// Callback for parent handling
this.props.onError?.(domainError, errorInfo);
}
private reportToService(error: DomainError, errorInfo: ErrorInfo) {
// Sentry, DataDog, etc.
if (typeof window !== 'undefined' && window.Sentry) {
window.Sentry.captureException(error, {
contexts: {
react: { componentStack: errorInfo.componentStack },
domain: serializeError(error),
},
level: this.props.isolationLevel === 'root' ? 'fatal' : 'error',
});
}
}
reset = () => {
this.setState({ error: null });
};
reportError = (error: unknown, context?: Record<string, unknown>) => {
const domainError = Errors.system(error);
this.reportToService(domainError, { componentStack: '' } as ErrorInfo);
};
render() {
const contextValue: ErrorBoundaryContextValue = {
error: this.state.error,
reset: this.reset,
reportError: this.reportError,
};
if (this.state.error) {
return (
<ErrorBoundaryContext.Provider value={contextValue}>
{this.props.fallback({
error: this.state.error,
reset: this.reset,
})}
</ErrorBoundaryContext.Provider>
);
}
return (
<ErrorBoundaryContext.Provider value={contextValue}>
{this.props.children}
</ErrorBoundaryContext.Provider>
);
}
}
Fallback Components by Isolation Level
// components/ErrorFallbacks.tsx
import type { DomainError } from '../errors';
interface FallbackProps {
error: DomainError;
reset: () => void;
}
export function RootErrorFallback({ error, reset }: FallbackProps) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md p-8 bg-white rounded-lg shadow-lg">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Something went wrong
</h1>
<p className="text-gray-600 mb-6">
We've been notified and are working on a fix.
</p>
<div className="space-y-3">
<button
onClick={() => window.location.reload()}
className="w-full py-2 px-4 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Refresh Page
</button>
<button
onClick={() => window.location.href = '/'}
className="w-full py-2 px-4 border border-gray-300 rounded hover:bg-gray-50"
>
Go to Homepage
</button>
</div>
{process.env.NODE_ENV === 'development' && (
<details className="mt-6">
<summary className="cursor-pointer text-sm text-gray-500">
Error Details
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(error, null, 2)}
</pre>
</details>
)}
</div>
</div>
);
}
export function RouteErrorFallback({ error, reset }: FallbackProps) {
const isNotFound = error._tag === 'NotFoundError';
const isAuth = error._tag === 'AuthorizationError';
if (isNotFound) {
return (
<div className="p-8 text-center">
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
<p className="text-gray-600 mb-4">
The page you're looking for doesn't exist.
</p>
<a href="/" className="text-blue-600 hover:underline">
Return home
</a>
</div>
);
}
if (isAuth) {
return (
<div className="p-8 text-center">
<h2 className="text-xl font-semibold mb-2">Access Denied</h2>
<p className="text-gray-600 mb-4">
You don't have permission to view this page.
</p>
<a href="/login" className="text-blue-600 hover:underline">
Sign in with a different account
</a>
</div>
);
}
return (
<div className="p-8 text-center">
<h2 className="text-xl font-semibold mb-2">Failed to Load</h2>
<p className="text-gray-600 mb-4">
This page encountered an error.
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Try Again
</button>
</div>
);
}
export function WidgetErrorFallback({ error, reset }: FallbackProps) {
return (
<div className="p-4 border border-red-200 bg-red-50 rounded">
<p className="text-sm text-red-800 mb-2">
This section couldn't load.
</p>
<button
onClick={reset}
className="text-sm text-red-600 hover:underline"
>
Retry
</button>
</div>
);
}
Composing Boundaries in the App
// app/layout.tsx
import { ErrorBoundary } from '../components/ErrorBoundary';
import { RootErrorFallback } from '../components/ErrorFallbacks';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ErrorBoundary
isolationLevel="root"
fallback={RootErrorFallback}
onError={(error) => {
// Could trigger incident response for critical errors
if (error._tag === 'SystemError') {
alertOpsTeam(error);
}
}}
>
<AppShell>{children}</AppShell>
</ErrorBoundary>
</body>
</html>
);
}
// app/dashboard/layout.tsx
import { ErrorBoundary } from '../../components/ErrorBoundary';
import { RouteErrorFallback } from '../../components/ErrorFallbacks';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
isolationLevel="route"
fallback={RouteErrorFallback}
>
<DashboardShell>{children}</DashboardShell>
</ErrorBoundary>
);
}
Async Error Handling Patterns
The useAsyncError Hook
React error boundaries don't catch errors in event handlers or async code. Bridge the gap:
// hooks/useAsyncError.ts
import { useState, useCallback } from 'react';
import { useErrorBoundary } from '../components/ErrorBoundary';
import { Result } from '../lib/result';
import type { DomainError } from '../errors';
interface UseAsyncErrorResult<T, E extends DomainError> {
execute: (...args: Parameters<T>) => Promise<void>;
data: Awaited<ReturnType<T>> extends Result<infer D, E> ? D | null : never;
error: E | null;
isLoading: boolean;
reset: () => void;
}
export function useAsyncError<
T extends (...args: any[]) => Promise<Result<any, E>>,
E extends DomainError = DomainError
>(
asyncFn: T,
options: {
throwOnError?: boolean; // Escalate to error boundary
onSuccess?: (data: any) => void;
onError?: (error: E) => void;
} = {}
): UseAsyncErrorResult<T, E> {
const [data, setData] = useState<any>(null);
const [error, setError] = useState<E | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { reportError } = useErrorBoundary();
const execute = useCallback(
async (...args: Parameters<T>) => {
setIsLoading(true);
setError(null);
try {
const result = await asyncFn(...args);
if (result.ok) {
setData(result.value);
options.onSuccess?.(result.value);
} else {
setError(result.error);
options.onError?.(result.error);
if (options.throwOnError) {
// This will be caught by the nearest error boundary
throw result.error;
}
}
} catch (e) {
// Unexpected error (not a Result)
reportError(e);
if (options.throwOnError) throw e;
} finally {
setIsLoading(false);
}
},
[asyncFn, options, reportError]
);
const reset = useCallback(() => {
setData(null);
setError(null);
setIsLoading(false);
}, []);
return { execute, data, error, isLoading, reset };
}
Retry Logic with Exponential Backoff
// lib/retry.ts
import { Result } from './result';
import type { DomainError } from '../errors';
import { isRetryable } from '../errors';
interface RetryOptions {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
shouldRetry?: (error: DomainError, attempt: number) => boolean;
onRetry?: (error: DomainError, attempt: number, delayMs: number) => void;
}
const defaultOptions: RetryOptions = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
shouldRetry: isRetryable,
};
export async function withRetry<T, E extends DomainError>(
operation: () => Promise<Result<T, E>>,
options: Partial<RetryOptions> = {}
): Promise<Result<T, E>> {
const opts = { ...defaultOptions, ...options };
let lastError: E | null = null;
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
const result = await operation();
if (result.ok) {
return result;
}
lastError = result.error;
// Check if we should retry
const shouldRetry = opts.shouldRetry!(result.error, attempt);
const hasMoreAttempts = attempt < opts.maxAttempts;
if (!shouldRetry || !hasMoreAttempts) {
return result;
}
// Calculate delay with exponential backoff + jitter
const exponentialDelay = opts.baseDelayMs * Math.pow(2, attempt - 1);
const jitter = Math.random() * 0.3 * exponentialDelay;
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs);
opts.onRetry?.(result.error, attempt, delay);
await sleep(delay);
}
return Result.err(lastError!);
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
const result = await withRetry(
() => api.fetchUserData(userId),
{
maxAttempts: 3,
baseDelayMs: 500,
onRetry: (error, attempt, delay) => {
console.warn(`Retry attempt ${attempt} after ${delay}ms`, {
error: error._tag,
correlationId: error.correlationId,
});
},
}
);
Building a Consistent Error Language
Error Message Guidelines
// errors/messages.ts
/**
* User-facing messages should be:
* - Actionable: Tell users what they can do
* - Specific: Avoid generic "something went wrong"
* - Blame-free: Never imply user fault for system errors
*/
export const UserMessages = {
validation: {
required: (field: string) => `${field} is required`,
email: () => 'Please enter a valid email address',
minLength: (field: string, min: number) =>
`${field} must be at least ${min} characters`,
maxLength: (field: string, max: number) =>
`${field} must be no more than ${max} characters`,
},
network: {
offline: () => "You're offline. Please check your connection and try again.",
timeout: () => 'This is taking longer than expected. Please try again.',
serverError: () => "We're having trouble right now. Please try again in a few minutes.",
},
auth: {
sessionExpired: () => 'Your session has expired. Please sign in again.',
unauthorized: () => "You don't have access to this resource.",
accountLocked: () => 'Your account has been locked. Please contact support.',
},
notFound: {
generic: (resource: string) => `This ${resource} no longer exists or was moved.`,
page: () => "The page you're looking for doesn't exist.",
},
} as const;
// Map domain errors to user messages
export function toUserMessage(error: DomainError): string {
switch (error._tag) {
case 'ValidationError':
return error.message; // Already user-friendly
case 'NetworkError':
if (!navigator.onLine) return UserMessages.network.offline();
if (error.statusCode === 408) return UserMessages.network.timeout();
return UserMessages.network.serverError();
case 'AuthorizationError':
return UserMessages.auth.unauthorized();
case 'NotFoundError':
return UserMessages.notFound.generic(error.resourceType);
case 'BusinessRuleError':
return error.message; // Business messages are typically user-friendly
case 'SystemError':
return UserMessages.network.serverError(); // Never expose system details
default:
return UserMessages.network.serverError();
}
}
Logging Strategy
// lib/logger.ts
import type { DomainError } from '../errors';
import { serializeError } from '../errors';
import { getCorrelationId } from './context';
type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
interface LogContext {
correlationId?: string;
userId?: string;
[key: string]: unknown;
}
function createLogger() {
const log = (level: LogLevel, message: string, context: LogContext = {}) => {
const entry = {
timestamp: new Date().toISOString(),
level,
message,
correlationId: context.correlationId ?? getCorrelationId(),
...context,
};
// In production, send to log aggregator
if (process.env.NODE_ENV === 'production') {
// DataDog, CloudWatch, etc.
fetch('/api/logs', {
method: 'POST',
body: JSON.stringify(entry),
keepalive: true, // Ensure delivery on page unload
}).catch(() => {}); // Fire and forget
}
// Always log to console in development
console[level === 'fatal' ? 'error' : level](
`[${level.toUpperCase()}] ${message}`,
context
);
};
return {
debug: (msg: string, ctx?: LogContext) => log('debug', msg, ctx),
info: (msg: string, ctx?: LogContext) => log('info', msg, ctx),
warn: (msg: string, ctx?: LogContext) => log('warn', msg, ctx),
error: (msg: string, ctx?: LogContext) => log('error', msg, ctx),
fatal: (msg: string, ctx?: LogContext) => log('fatal', msg, ctx),
// Structured error logging
logError: (error: DomainError, context?: Record<string, unknown>) => {
const level = error._tag === 'SystemError' ? 'error' : 'warn';
log(level, error.message, {
error: serializeError(error),
...context,
});
},
};
}
export const logger = createLogger();
Anti-Patterns to Avoid
1. Catching and Ignoring
// BAD: Silent failure
try {
await saveData();
} catch (e) {
// "It's fine, probably"
}
// GOOD: Explicit handling or propagation
const result = await saveData();
if (!result.ok) {
if (result.error._tag === 'NetworkError' && result.error.retryable) {
// Queue for retry
retryQueue.add(saveData);
} else {
// Propagate for boundary handling
throw result.error;
}
}
2. Over-Catching
// BAD: Catches everything including programmer errors
try {
const user = await fetchUser(id);
const profile = user.profiles[0].data; // Might throw TypeError
return processProfile(profile);
} catch (e) {
return null; // Hides bugs
}
// GOOD: Narrow catches
const userResult = await fetchUser(id);
if (!userResult.ok) {
return Result.err(userResult.error);
}
const user = userResult.value;
if (!user.profiles?.[0]?.data) {
return Result.err(Errors.validation('user', user.id, 'missing profile data'));
}
return processProfile(user.profiles[0].data);
3. Stringly-Typed Errors
// BAD: Error types as strings
throw new Error('VALIDATION_ERROR: email is invalid');
// Parsing required
if (error.message.startsWith('VALIDATION_ERROR')) {
// Fragile string matching
}
// GOOD: Structured types
throw Errors.validation('email', input.email, 'must be valid email');
// Type-safe matching
if (error._tag === 'ValidationError') {
// Full type information available
}
4. Error Transformation Loss
// BAD: Information loss during transformation
try {
await externalApi.call();
} catch (e) {
throw new Error('API call failed'); // Lost: status code, retryability, original message
}
// GOOD: Preserve context
try {
await externalApi.call();
} catch (e) {
throw Errors.network(url, {
statusCode: e.response?.status,
message: e.message,
cause: e,
retryable: e.response?.status >= 500,
});
}
Conclusion
Error handling is not a tax you pay to satisfy the compiler. It's a design dimension that affects:
- User experience: Clear, actionable feedback vs. mysterious failures
- Debuggability: Structured context vs. stack trace archaeology
- Reliability: Graceful degradation vs. cascading failures
- Maintainability: Explicit contracts vs. "what could this throw?"
The patterns in this article—typed errors, Result types, strategic boundaries, correlation IDs—form a coherent system. Adopt them incrementally:
- Start with a domain error taxonomy
- Add Result types to new code
- Place error boundaries at natural isolation points
- Add correlation IDs to requests
- Build consistent user-facing messages
The investment pays dividends in every debugging session, every incident response, and every user interaction that doesn't end in confusion.
Further Reading
- Functional Error Handling (Effect-TS) - Full effect system with typed errors
- Parse, Don't Validate - Making illegal states unrepresentable
- Railway Oriented Programming - Error handling as data flow
- The Error Model (Joe Duffy) - Deep dive on error handling philosophy
What did you think?