OneApp Docs
Guides

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

packages/types/src/async-result.ts
/**
 * 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

Simple AsyncResult function
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))
    };
  }
}
Consuming AsyncResult
// 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

Chaining AsyncResult functions
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;
}
Multiple chained operations
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

packages/utils/src/async-result.ts
/**
 * 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:

packages/types/src/api.ts
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

app/api/users/[id]/route.ts
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:

packages/shared/src/error-codes.ts
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 CodeHTTP StatusDescription
NOT_FOUND404Resource doesn't exist
VALIDATION_ERROR400Invalid input data
UNAUTHORIZED401Missing or invalid auth
FORBIDDEN403Authenticated but not allowed
CONFLICT409Resource already exists
BAD_REQUEST400Malformed request
INTERNAL_ERROR500Unexpected server error
DATABASE_ERROR500Database operation failed
EXTERNAL_SERVICE_ERROR502Third-party API failed
TIMEOUT_ERROR504Operation timed out

React error boundaries

Error boundaries catch React errors and prevent entire app crashes.

Creating an error boundary

components/ErrorBoundary.tsx
"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

app/layout.tsx
import { ErrorBoundary } from "#/components/ErrorBoundary";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ErrorBoundary>{children}</ErrorBoundary>
      </body>
    </html>
  );
}
Wrap risky components
<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:

Zod validation with AsyncResult
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);
}
Server Action with validation
"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

LevelUse CaseExample
errorUnexpected failures, bugsDatabase connection failed
warnRecoverable issues, deprecationsAPI rate limit approaching
infoImportant eventsUser logged in
debugDevelopment onlyRequest/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

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

packages/types/src/errors.ts
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";
  }
}
Usage with AsyncResult
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

lib/sentry.ts
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 });
  }
}
Usage
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>
  );
}

On this page