Error handling

Centralized error handling system with custom error classes

This boilerplate includes a comprehensive error handling system with custom error classes and centralized handlers for consistent error handling across both server and client.

Overview

The error handling system provides:

  • Custom error classes - Type-safe errors with HTTP status codes
  • Server middleware - Automatic error transformation
  • Client composable - Consistent error handling with toast notifications
  • Security - Sanitized error messages in production

Custom error classes

Located in shared/errors/custom-errors.ts, these type-safe error classes can be used throughout your application:

throw new NotFoundError('Product')
throw new ValidationError('Invalid email', validationDetails)
throw new UnauthorizedError('Login required')
throw new ForbiddenError('Insufficient permissions')
throw new ConflictError('Email already exists')
throw new InternalServerError('Database connection failed')

Available error types

Error classStatus codeWhen to use
ValidationError400Invalid input data
UnauthorizedError401Authentication required
ForbiddenError403Insufficient permissions
NotFoundError404Resource doesn't exist
ConflictError409Resource already exists
InternalServerError500Unexpected server errors

Server-side error handling

Global error middleware

The error handler (server/middleware/99-error-handler.ts) automatically catches all unhandled errors and transforms them into consistent HTTP responses. This means you don't need try-catch blocks in most API routes.

All API errors follow this format:

{
  statusCode: 404,
  statusMessage: "Product not found",
  data: {
    code: "NOT_FOUND",
    message: "Product not found",
    details: { /* Only in development */ }
  }
}

Usage in server services

Throw custom errors for business logic violations:

import { NotFoundError } from '@@/shared/errors/custom-errors'
import prisma from '@@/lib/prisma'

export async function getProduct(id: string) {
  const product = await prisma.product.findUnique({
    where: { id },
  })

  if (!product) {
    throw new NotFoundError('Product')
  }

  return product
}

Database errors and other unexpected errors automatically bubble up to the middleware, which logs them and returns appropriate responses.

Usage in API routes

Validate input with Zod, then call services:

import { ValidationError } from '@@/shared/errors/custom-errors'
import { createProductSchema } from '@@/shared/schemas'

export default defineEventHandler(async event => {
  const body = await readBody(event)

  const parseResult = createProductSchema.safeParse(body)
  if (!parseResult.success) {
    throw new ValidationError('Invalid request data', parseResult.error.issues)
  }

  const product = await createProduct(parseResult.data)
  return product
})
The error handler middleware automatically catches ZodError instances and transforms them into 400 responses, so you can also use .parse() instead of .safeParse() if you prefer.

Client-side error handling

Error handler composable

Use useErrorHandler in components for consistent error handling:

const { handleError, handleSuccess } = useErrorHandler()

const handleSubmit = async () => {
  try {
    await createProduct(formData)
    handleSuccess('Product created successfully')
  } catch (error) {
    handleError(error, 'Failed to create product')
  }
}

The composable automatically:

  • Parses server error responses
  • Shows toast notifications to users
  • Logs detailed errors to console (development only)
  • Provides user-friendly messages based on status codes

Client services

Client services let errors throw - the component layer handles them:

/**
 * Create a new product
 * Throws error on failure - handle with useErrorHandler composable
 */
export const createProduct = async (data: ProductPayload): Promise<Product> => {
  return await $fetch<Product>('/api/products', {
    method: 'POST',
    body: data,
  })
}

Component example

<script setup lang="ts">
import { createProduct } from '@@/app/services/products-client-service'

const { handleError, handleSuccess } = useErrorHandler()
const isSubmitting = ref(false)

const formData = reactive({
  name: '',
  price: 0,
})

const handleSubmit = async () => {
  isSubmitting.value = true

  try {
    await createProduct(formData)
    handleSuccess('Product created successfully')
    // Reset form, navigate, etc.
  } catch (error) {
    handleError(error, 'Failed to create product')
  } finally {
    isSubmitting.value = false
  }
}
</script>

Common patterns

Simple resource operations

// Server service
export async function getProducts() {
  return await prisma.product.findMany()
}

// API route
export default defineEventHandler(async () => {
  return await getProducts()
})

// Client service
export const fetchProducts = async () => {
  return await $fetch<Product[]>('/api/products')
}

// Component
const { handleError } = useErrorHandler()

try {
  products.value = await fetchProducts()
} catch (error) {
  handleError(error, 'Failed to load products')
}

Business logic with validation

export async function purchaseProduct(userId: string, productId: string) {
  const user = await prisma.user.findUnique({ where: { id: userId } })
  if (!user) {
    throw new NotFoundError('User')
  }

  if (!user.emailVerified) {
    throw new ForbiddenError('Email verification required')
  }

  const product = await prisma.product.findUnique({ where: { id: productId } })
  if (!product) {
    throw new NotFoundError('Product')
  }

  if (product.stock < 1) {
    throw new ConflictError('Product out of stock')
  }

  // Create purchase...
  return purchase
}

Operations with fallbacks

When you need to handle errors gracefully without aborting:

export async function updateLicenseKeyUsage(licenseKeyId: string): Promise<void> {
  try {
    await prisma.licenseKey.update({
      where: { id: licenseKeyId },
      data: {
        usageCount: { increment: 1 },
        lastUsedAt: new Date(),
      },
    })
  } catch (error) {
    logger.error('Error updating license key usage:', error)
    // Don't throw - usage tracking failure shouldn't block downloads
  }
}

API reference

Custom error classes

new ValidationError(message: string, details?: unknown)
new UnauthorizedError(message?: string)
new ForbiddenError(message?: string)
new NotFoundError(resource: string)
new ConflictError(message: string)
new InternalServerError(message?: string)

Error handler composable

const {
  handleError,   // (error: unknown, customMessage?: string) => void
  handleSuccess, // (message?: string) => void
} = useErrorHandler()

Production considerations

The error handling system automatically sanitizes error messages in production, hiding stack traces and sensitive details while logging full error information server-side.

For production error monitoring, consider integrating services like Sentry, LogRocket, or Datadog. The error handler middleware can be extended to send errors to these services:

import * as Sentry from '@sentry/node'

export const onError = (error: unknown) => {
  // Existing error handling...
  
  if (process.env.NODE_ENV === 'production') {
    Sentry.captureException(error)
  }
  
  // Return error response...
}
  • shared/errors/custom-errors.ts - Custom error class definitions
  • server/middleware/99-error-handler.ts - Global server error handler
  • app/composables/use-error-handler.ts - Client error handler composable