Error Handling

Comprehensive guide to error handling in ShipKit

Error Handling

This document provides a comprehensive guide to error handling in ShipKit, including error types, handling strategies, and best practices.

Error Types

Custom Error Classes

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 500,
    public details?: unknown
  ) {
    super(message)
    this.name = 'AppError'
  }
}

export class ValidationError extends AppError {
  constructor(message: string, details?: unknown) {
    super(message, 'VALIDATION_ERROR', 400, details)
    this.name = 'ValidationError'
  }
}

export class AuthenticationError extends AppError {
  constructor(message: string = 'Unauthorized') {
    super(message, 'AUTHENTICATION_ERROR', 401)
    this.name = 'AuthenticationError'
  }
}

export class AuthorizationError extends AppError {
  constructor(message: string = 'Forbidden') {
    super(message, 'AUTHORIZATION_ERROR', 403)
    this.name = 'AuthorizationError'
  }
}

export class NotFoundError extends AppError {
  constructor(message: string) {
    super(message, 'NOT_FOUND', 404)
    this.name = 'NotFoundError'
  }
}

Error Factory

// src/lib/error-factory.ts
import { type ZodError } from 'zod'
import { Prisma } from '@prisma/client'
import {
  AppError,
  ValidationError,
  NotFoundError,
  AuthenticationError,
} from './errors'

export function createError(error: unknown): AppError {
  if (error instanceof AppError) {
    return error
  }

  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    switch (error.code) {
      case 'P2002':
        return new ValidationError('Unique constraint violation', {
          field: error.meta?.target,
        })
      case 'P2025':
        return new NotFoundError('Record not found')
      default:
        return new AppError(
          'Database error',
          'DATABASE_ERROR',
          500,
          error.code
        )
    }
  }

  if (error instanceof Prisma.PrismaClientValidationError) {
    return new ValidationError('Invalid data provided')
  }

  if ((error as ZodError)?.issues) {
    return new ValidationError('Validation error', {
      issues: (error as ZodError).issues,
    })
  }

  if (error instanceof Error) {
    return new AppError(error.message, 'UNKNOWN_ERROR')
  }

  return new AppError('An unknown error occurred', 'UNKNOWN_ERROR')
}

API Error Handling

Route Handlers

// src/app/api/v1/posts/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { createError } from '@/lib/error-factory'
import { db } from '@/server/db'

const postSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().optional(),
})

export async function POST(req: Request) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      throw new AuthenticationError()
    }

    const json = await req.json()
    const body = postSchema.parse(json)

    const post = await db.post.create({
      data: {
        ...body,
        authorId: session.user.id,
      },
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    const appError = createError(error)

    return NextResponse.json(
      {
        error: {
          message: appError.message,
          code: appError.code,
          details: appError.details,
        },
      },
      { status: appError.statusCode }
    )
  }
}

Server Actions

// src/server/actions/posts.ts
'use server'

import { revalidatePath } from 'next/cache'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
import { createError } from '@/lib/error-factory'
import { db } from '@/server/db'

const createPostSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().optional(),
})

export async function createPost(data: z.infer<typeof createPostSchema>) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      throw new AuthenticationError()
    }

    const validated = createPostSchema.parse(data)

    const post = await db.post.create({
      data: {
        ...validated,
        authorId: session.user.id,
      },
    })

    revalidatePath('/dashboard/posts')
    return { data: post }
  } catch (error) {
    const appError = createError(error)
    return {
      error: {
        message: appError.message,
        code: appError.code,
        details: appError.details,
      },
    }
  }
}

Client Error Handling

Error Boundary

// src/components/error-boundary.tsx
'use client'

import { type ReactNode } from 'react'
import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'

interface ErrorBoundaryProps {
  children: ReactNode
  fallback: ReactNode
}

interface ErrorBoundaryState {
  hasError: boolean
  error?: Error
}

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('ErrorBoundary caught an error:', error, errorInfo)
    Sentry.captureException(error, { extra: errorInfo })
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback
    }

    return this.props.children
  }
}

export function ErrorFallback({
  error,
  resetErrorBoundary,
}: {
  error: Error
  resetErrorBoundary: () => void
}) {
  useEffect(() => {
    // Log error to reporting service
    Sentry.captureException(error)
  }, [error])

  return (
    <div className="p-4">
      <h2 className="text-lg font-semibold">Something went wrong</h2>
      <pre className="mt-2 text-sm text-red-500">{error.message}</pre>
      <button
        onClick={resetErrorBoundary}
        className="mt-4 rounded bg-blue-500 px-4 py-2 text-white"
      >
        Try again
      </button>
    </div>
  )
}

Error Hook

// src/hooks/use-error.ts
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import * as Sentry from '@sentry/nextjs'

interface ErrorState {
  message: string
  code?: string
  details?: unknown
}

export function useError() {
  const [error, setError] = useState<ErrorState | null>(null)

  const handleError = useCallback((error: unknown) => {
    console.error('Error caught:', error)

    // Report to Sentry
    Sentry.captureException(error)

    // Parse error
    let errorState: ErrorState

    if (error instanceof Error) {
      errorState = {
        message: error.message,
        code: (error as any).code,
        details: (error as any).details,
      }
    } else if (typeof error === 'string') {
      errorState = { message: error }
    } else {
      errorState = { message: 'An unknown error occurred' }
    }

    // Set error state
    setError(errorState)

    // Show toast notification
    toast.error(errorState.message)

    return errorState
  }, [])

  const clearError = useCallback(() => {
    setError(null)
  }, [])

  return {
    error,
    handleError,
    clearError,
  }
}

Form Error Handling

Form Validation

// src/components/forms/post-form.tsx
'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useError } from '@/hooks/use-error'
import { createPost } from '@/server/actions/posts'

const postSchema = z.object({
  title: z.string().min(1, 'Title is required').max(255),
  content: z.string().optional(),
})

type PostFormData = z.infer<typeof postSchema>

export function PostForm() {
  const { handleError } = useError()
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PostFormData>({
    resolver: zodResolver(postSchema),
  })

  const onSubmit = async (data: PostFormData) => {
    try {
      const result = await createPost(data)

      if (result.error) {
        throw result.error
      }

      // Handle success
    } catch (error) {
      handleError(error)
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          {...register('title')}
          className={errors.title ? 'border-red-500' : ''}
        />
        {errors.title && (
          <p className="text-sm text-red-500">{errors.title.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          {...register('content')}
          className={errors.content ? 'border-red-500' : ''}
        />
        {errors.content && (
          <p className="text-sm text-red-500">{errors.content.message}</p>
        )}
      </div>

      <button type="submit">Create Post</button>
    </form>
  )
}

Error Monitoring

Sentry Configuration

// src/lib/monitoring.ts
import * as Sentry from '@sentry/nextjs'
import { env } from '@/env.mjs'

export function initErrorMonitoring() {
  Sentry.init({
    dsn: env.SENTRY_DSN,
    environment: env.NODE_ENV,
    tracesSampleRate: 1.0,
    beforeSend(event) {
      // Modify or filter events before sending to Sentry
      if (env.NODE_ENV === 'development') {
        return null
      }

      return event
    },
  })
}

export function captureError(
  error: unknown,
  context?: Record<string, unknown>
) {
  console.error('Error:', error)

  if (env.NODE_ENV === 'development') {
    return
  }

  Sentry.captureException(error, {
    extra: context,
  })
}

Error Logging

// src/lib/logger.ts
import pino from 'pino'
import { env } from '@/env.mjs'

export const logger = pino({
  level: env.LOG_LEVEL || 'info',
  transport: {
    target: 'pino-pretty',
    options: {
      colorize: true,
    },
  },
})

export function logError(
  error: unknown,
  context?: Record<string, unknown>
) {
  if (error instanceof Error) {
    logger.error(
      {
        err: {
          message: error.message,
          stack: error.stack,
          ...error,
        },
        ...context,
      },
      error.message
    )
  } else {
    logger.error({ err: error, ...context }, 'Unknown error')
  }
}

Best Practices

Error Prevention

  1. Type Safety

    // Use TypeScript and Zod for runtime type checking
    const userSchema = z.object({
      email: z.string().email(),
      name: z.string().min(2),
    })
    
    type User = z.infer<typeof userSchema>
    
  2. Null Checks

    // Use optional chaining and nullish coalescing
    const userName = user?.name ?? 'Anonymous'
    
  3. Assertions

    // Use assertions for type narrowing
    function processUser(user: unknown) {
      assert(isUser(user), 'Invalid user data')
      // user is now typed as User
    }
    

Error Recovery

  1. Retry Logic

    // src/lib/retry.ts
    export async function retry<T>(
      fn: () => Promise<T>,
      options: {
        attempts?: number
        delay?: number
      } = {}
    ): Promise<T> {
      const { attempts = 3, delay = 1000 } = options
    
      for (let i = 0; i < attempts; i++) {
        try {
          return await fn()
        } catch (error) {
          if (i === attempts - 1) throw error
          await new Promise((resolve) => setTimeout(resolve, delay))
        }
      }
    
      throw new Error('Retry failed')
    }
    
  2. Fallback Values

    // src/lib/fallback.ts
    export function withFallback<T>(
      fn: () => T,
      fallback: T
    ): T {
      try {
        return fn()
      } catch {
        return fallback
      }
    }
    
  3. Circuit Breaker

    // src/lib/circuit-breaker.ts
    export class CircuitBreaker {
      private failures = 0
      private lastFailure?: Date
      private readonly threshold: number
      private readonly timeout: number
    
      constructor(threshold = 5, timeout = 60000) {
        this.threshold = threshold
        this.timeout = timeout
      }
    
      async execute<T>(fn: () => Promise<T>): Promise<T> {
        if (this.isOpen()) {
          throw new Error('Circuit breaker is open')
        }
    
        try {
          const result = await fn()
          this.reset()
          return result
        } catch (error) {
          this.recordFailure()
          throw error
        }
      }
    
      private isOpen(): boolean {
        if (this.failures < this.threshold) return false
        if (!this.lastFailure) return false
    
        const now = new Date()
        const diff = now.getTime() - this.lastFailure.getTime()
        return diff < this.timeout
      }
    
      private recordFailure() {
        this.failures++
        this.lastFailure = new Date()
      }
    
      private reset() {
        this.failures = 0
        this.lastFailure = undefined
      }
    }
    

Error Documentation

  1. Error Codes

    // src/lib/error-codes.ts
    export const ErrorCodes = {
      VALIDATION_ERROR: 'E001',
      AUTHENTICATION_ERROR: 'E002',
      AUTHORIZATION_ERROR: 'E003',
      NOT_FOUND_ERROR: 'E004',
      DATABASE_ERROR: 'E005',
    } as const
    
  2. Error Messages

    // src/lib/error-messages.ts
    export const ErrorMessages = {
      [ErrorCodes.VALIDATION_ERROR]: 'Invalid input data provided',
      [ErrorCodes.AUTHENTICATION_ERROR]: 'Authentication required',
      [ErrorCodes.AUTHORIZATION_ERROR]: 'Insufficient permissions',
      [ErrorCodes.NOT_FOUND_ERROR]: 'Resource not found',
      [ErrorCodes.DATABASE_ERROR]: 'Database operation failed',
    } as const
    
  3. Error Documentation

    // src/lib/error-docs.ts
    export const ErrorDocs = {
      [ErrorCodes.VALIDATION_ERROR]: {
        description: 'Invalid input data was provided',
        possibleCauses: [
          'Missing required fields',
          'Invalid field format',
          'Field value out of range',
        ],
        solutions: [
          'Check input data against schema',
          'Validate data before submission',
          'Review API documentation',
        ],
      },
      // ... other error documentation
    } as const