Error Handling
Master type-safe error handling across the OneApp monorepo using the AsyncResult pattern, React error boundaries, API error responses, and Zod validation.
Already familiar with error patterns?
Why error handling matters
Poor error handling creates serious problems:
- Silent failures — Errors swallowed by try/catch, users see broken UIs with no explanation
- Type unsafety — Try/catch doesn't provide type information about success/failure cases
- Inconsistent errors — Every API endpoint returns different error formats
- Missing context — Logs show errors but no information about what caused them
- Security leaks — Error messages expose internal implementation details
- Poor UX — Users see generic "Something went wrong" with no recovery path
OneApp's error handling uses AsyncResult pattern (type-safe discriminated unions), standardized API error codes (NOT_FOUND, VALIDATION_ERROR, INTERNAL_ERROR), React error boundaries (graceful UI degradation), Zod validation (catch bad input early), structured logging (errors with context), and Sentry integration (production error tracking) — ensuring errors are caught, logged, and handled gracefully.
Production-ready with AsyncResult in all async functions, error boundaries around risky components, consistent API error format, Zod schemas for all user input, never logging sensitive data, and Sentry tracking all production errors.
Use cases
Master error handling to:
- Prevent bugs — Catch errors at compile time with AsyncResult types
- Debug faster — Structured logs show exact context of failures
- Improve UX — Show meaningful error messages, not generic failures
- Secure APIs — Don't leak implementation details in errors
- Handle failures gracefully — Error boundaries prevent entire app crashes
- Track production issues — Sentry alerts on real user errors
Quick Start
Essential error handling pattern
1. Use AsyncResult for functions that can fail:
import type { AsyncResult } from "@repo/types";
async function fetchUser(id: UserId): Promise<AsyncResult<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return { success: false, error: new Error("User not found") };
}
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error("Unknown error")
};
}
}2. Handle results with type-safe branching:
const result = await fetchUser(userId);
if (result.success) {
console.log(result.data.name); // TypeScript knows: User
} else {
console.error(result.error.message); // TypeScript knows: Error
}3. Display errors in UI:
export default async function Page({ params }: { params: { id: string } }) {
const result = await fetchUser(params.id);
if (!result.success) {
return (
<div className="p-4 text-red-600">
<h1>Error</h1>
<p>{result.error.message}</p>
);
}
return <div>Welcome, {result.data.name};
}That's it! AsyncResult provides compile-time guarantees that errors are handled.
AsyncResult pattern
Type definition
/**
* Discriminated union for async operations that can fail.
*
* @template T - The success data type
*/
export type AsyncResult<T> = { success: true; data: T } | { success: false; error: Error };Benefits:
- ✅ Forces explicit error handling (TypeScript error if not checked)
- ✅ Type-safe — TypeScript narrows types in if/else branches
- ✅ No try/catch spreading — Handle errors at point of use
- ✅ Composable — Easy to chain operations
- ✅ Self-documenting — Function signature shows it can fail
Basic usage
async function getUser(id: UserId): Promise<AsyncResult<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return { success: false, error: new Error("User not found") };
}
return { success: true, data: user };
} catch (error) {
// Always convert to Error type
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}// In component or another function
const result = await getUser(userId);
if (result.success) {
// TypeScript knows result.data is User
console.log(result.data.email);
console.log(result.data.name);
} else {
// TypeScript knows result.error is Error
console.error(result.error.message);
console.error(result.error.stack);
}Chaining operations
async function getUserOrders(userId: UserId): Promise<AsyncResult<Order[]>> {
// First get the user
const userResult = await getUser(userId);
if (!userResult.success) {
// Pass through the error (early return)
return userResult;
}
// User exists, fetch their orders
const ordersResult = await fetchOrders(userResult.data.id);
return ordersResult;
}async function getUserOrderTotal(userId: UserId): Promise<AsyncResult<number>> {
const userResult = await getUser(userId);
if (!userResult.success) return userResult;
const ordersResult = await getUserOrders(userResult.data.id);
if (!ordersResult.success) return ordersResult;
const total = ordersResult.data.reduce((sum, order) => sum + order.total, 0);
return { success: true, data: total };
}Helper utilities
/**
* Unwrap AsyncResult or throw error if failed
*/
export function unwrap<T>(result: AsyncResult<T>): T {
if (result.success) {
return result.data;
}
throw result.error;
}
/**
* Convert AsyncResult to value or null
*/
export function toNullable<T>(result: AsyncResult<T>): T | null {
return result.success ? result.data : null;
}
/**
* Map over successful result
*/
export function mapResult<T, U>(result: AsyncResult<T>, fn: (data: T) => U): AsyncResult<U> {
if (result.success) {
return { success: true, data: fn(result.data) };
}
return result;
}API error handling
Standard error response
All API endpoints use consistent error format:
export interface ApiError {
/** Human-readable error message */
error: string;
/** Machine-readable error code */
code: string;
/** Additional details (only in development) */
details?: unknown;
}
export interface ApiResponse<T> {
/** Response data */
data: T;
/** Response timestamp */
timestamp: string;
}API route pattern
import { NextRequest, NextResponse } from "next/server";
import type { ApiResponse, ApiError } from "@repo/types";
import { getUser } from "#/lib/users";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
): Promise<NextResponse<ApiResponse<User> | ApiError>> {
try {
const result = await getUser(params.id);
if (!result.success) {
return NextResponse.json(
{
error: result.error.message,
code: "NOT_FOUND"
},
{ status: 404 }
);
}
return NextResponse.json({
data: result.data,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error("Failed to fetch user:", {
userId: params.id,
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{
error: "Internal server error",
code: "INTERNAL_ERROR",
details: process.env.NODE_ENV === "development" ? String(error) : undefined
},
{ status: 500 }
);
}
}Error codes
Standardized error codes for consistency:
export const ErrorCodes = {
// Client errors (4xx)
NOT_FOUND: "NOT_FOUND",
VALIDATION_ERROR: "VALIDATION_ERROR",
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
CONFLICT: "CONFLICT",
BAD_REQUEST: "BAD_REQUEST",
// Server errors (5xx)
INTERNAL_ERROR: "INTERNAL_ERROR",
DATABASE_ERROR: "DATABASE_ERROR",
EXTERNAL_SERVICE_ERROR: "EXTERNAL_SERVICE_ERROR",
TIMEOUT_ERROR: "TIMEOUT_ERROR"
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];HTTP status code mapping:
| Error Code | HTTP Status | Description |
|---|---|---|
NOT_FOUND | 404 | Resource doesn't exist |
VALIDATION_ERROR | 400 | Invalid input data |
UNAUTHORIZED | 401 | Missing or invalid auth |
FORBIDDEN | 403 | Authenticated but not allowed |
CONFLICT | 409 | Resource already exists |
BAD_REQUEST | 400 | Malformed request |
INTERNAL_ERROR | 500 | Unexpected server error |
DATABASE_ERROR | 500 | Database operation failed |
EXTERNAL_SERVICE_ERROR | 502 | Third-party API failed |
TIMEOUT_ERROR | 504 | Operation timed out |
React error boundaries
Error boundaries catch React errors and prevent entire app crashes.
Creating an error boundary
"use client";
import { Component, type ReactNode } from "react";
import { Button } from "@repo/ui";
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// Log to console
console.error("Error caught by boundary:", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
// Send to error tracking (Sentry)
this.props.onError?.(error, errorInfo);
// In production, send to Sentry
if (process.env.NODE_ENV === "production") {
// import { captureException } from "@sentry/nextjs";
// captureException(error, { contexts: { react: errorInfo } });
}
}
render(): ReactNode {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="flex flex-col items-center justify-center p-8">
<h2 className="text-2xl font-bold text-red-600 mb-4">Something went wrong</h2>
<p className="text-gray-600 mb-4">
{this.state.error?.message ?? "An unexpected error occurred"}
</p>
<Button onClick={() => this.setState({ hasError: false })}>Try again</Button>
)
);
}
return this.props.children;
}
}Using error boundaries
import { ErrorBoundary } from "#/components/ErrorBoundary";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<ErrorBoundary>{children}</ErrorBoundary>
</body>
</html>
);
}<ErrorBoundary fallback={<div>Failed to load dashboard}>
<Dashboard />
</ErrorBoundary>When errors are caught:
- Component throws during rendering
- Component throws in lifecycle method
- Component throws in constructor
When errors are NOT caught:
- Event handlers (use try/catch)
- Async code (use AsyncResult)
- Server Components (use error.tsx)
Input validation
Use Zod to validate all user input:
import { z } from "zod/v4";
import type { AsyncResult } from "@repo/types";
const CreateUserSchema = z.object({
email: z.string().email("Invalid email format"),
name: z.string().min(2, "Name must be at least 2 characters"),
age: z.number().min(0, "Age must be positive").optional()
});
async function createUser(input: unknown): Promise<AsyncResult<User>> {
// Validate input first
const parsed = CreateUserSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
error: new Error(parsed.error.errors[0].message)
};
}
// Input is valid, proceed
return await saveUser(parsed.data);
}"use server";
import { z } from "zod/v4";
import { revalidatePath } from "next/cache";
const FormSchema = z.object({
title: z.string().min(1, "Title is required"),
content: z.string().min(10, "Content must be at least 10 characters")
});
export async function createPost(formData: FormData): Promise<AsyncResult<Post>> {
const parsed = FormSchema.safeParse({
title: formData.get("title"),
content: formData.get("content")
});
if (!parsed.success) {
return {
success: false,
error: new Error(parsed.error.errors[0].message)
};
}
const result = await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { success: true, data: result };
}Logging best practices
Structured logging
Always log with context:
// ✅ Good - structured logging with context
console.error("Failed to process order", {
orderId,
userId,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
// ❌ Bad - no context
console.error(error);
// ❌ Bad - unstructured
console.error(`Error: ${error.message}`);Log levels
| Level | Use Case | Example |
|---|---|---|
error | Unexpected failures, bugs | Database connection failed |
warn | Recoverable issues, deprecations | API rate limit approaching |
info | Important events | User logged in |
debug | Development only | Request/response details |
Never log sensitive data
// ❌ Bad - logs password
console.log("User logged in", { email, password });
// ❌ Bad - logs API key
console.log("API request", { headers });
// ✅ Good - sanitized
console.log("User logged in", { email, userId });
// ✅ Good - redacted
console.log("API request", {
url,
method,
headers: { ...headers, Authorization: "[REDACTED]" }
});Next steps
- Learn type safety: Type Safety →
- Explore types package: @repo/types →
- Review conventions: Coding Conventions →
For Developers: Advanced error handling patterns and Sentry integration
Advanced AsyncResult patterns
Combining multiple results
async function getUserProfile(userId: UserId): Promise<AsyncResult<UserProfile>> {
const [userResult, settingsResult, statsResult] = await Promise.all([
getUser(userId),
getUserSettings(userId),
getUserStats(userId)
]);
// Check all results
if (!userResult.success) return userResult;
if (!settingsResult.success) return settingsResult;
if (!statsResult.success) return statsResult;
// All succeeded, combine data
return {
success: true,
data: {
user: userResult.data,
settings: settingsResult.data,
stats: statsResult.data
}
};
}Retry logic with AsyncResult
async function withRetry<T>(fn: () => Promise<AsyncResult<T>>, maxRetries = 3): Promise<AsyncResult<T>> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const result = await fn();
if (result.success) {
return result;
}
lastError = result.error;
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
}
return {
success: false,
error: lastError ?? new Error("Max retries exceeded")
};
}
// Usage
const result = await withRetry(() => fetchExternalAPI(url));Timeout with AsyncResult
async function withTimeout<T>(fn: () => Promise<AsyncResult<T>>, timeoutMs: number): Promise<AsyncResult<T>> {
const timeoutPromise = new Promise<AsyncResult<T>>((resolve) =>
setTimeout(() => resolve({ success: false, error: new Error("Operation timed out") }), timeoutMs)
);
return Promise.race([fn(), timeoutPromise]);
}
// Usage
const result = await withTimeout(() => slowOperation(), 5000);Custom error classes
export class NotFoundError extends Error {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`);
this.name = "NotFoundError";
}
}
export class ValidationError extends Error {
constructor(
message: string,
public fields: Record<string, string>
) {
super(message);
this.name = "ValidationError";
}
}
export class UnauthorizedError extends Error {
constructor(message = "Unauthorized") {
super(message);
this.name = "UnauthorizedError";
}
}async function getUser(id: UserId): Promise<AsyncResult<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return {
success: false,
error: new NotFoundError("User", id)
};
}
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}Sentry integration
import * as Sentry from "@sentry/nextjs";
export function logError(error: Error, context?: Record<string, unknown>): void {
console.error("Error:", { error: error.message, stack: error.stack, ...context });
if (process.env.NODE_ENV === "production") {
Sentry.captureException(error, {
extra: context
});
}
}
export function logAsyncResult<T>(result: AsyncResult<T>, operation: string, context?: Record<string, unknown>): void {
if (!result.success) {
logError(result.error, { operation, ...context });
}
}const result = await getUser(userId);
if (!result.success) {
logError(result.error, { userId, operation: "getUser" });
return <ErrorPage error={result.error} />;
}Error aggregation
/**
* Collect all errors from multiple AsyncResults
*/
function collectErrors<T>(results: AsyncResult<T>[]): Error[] {
return results.filter((r): r is { success: false; error: Error } => !r.success).map((r) => r.error);
}
// Usage
const results = await Promise.all([validateEmail(email), validatePassword(password), validateAge(age)]);
const errors = collectErrors(results);
if (errors.length > 0) {
return {
success: false,
error: new ValidationError(
"Multiple validation errors",
errors.reduce((acc, e) => ({ ...acc, [e.message]: e.message }), {})
)
};
}Server vs Client error handling
Server Components
// app/users/[id]/page.tsx
export default async function UserPage({ params }: { params: { id: string } }) {
const result = await getUser(params.id);
if (!result.success) {
// Will be caught by nearest error.tsx
throw result.error;
}
return <div>Welcome, {result.data.name};
}Client Components
"use client";
import { useState } from "react";
export function UserForm() {
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
const result = await createUser(formData);
if (!result.success) {
setError(result.error.message);
return;
}
// Success
router.push(`/users/${result.data.id}`);
}
return (
<form onSubmit={handleSubmit}>
{error && <div className="text-red-600">{error}}
{/* form fields */}
</form>
);
}