Error Handling Patterns

Errors flow from services → controllers → HTTP responses through a typed result type. Services never throw for expected failures; controllers never contain business logic.

ErrorOr<T>

All service methods return ErrorOr<T> instead of throwing:

// backend/types/errorOr.ts
export type ErrorOr<T> =
  | { data: T; error: null } // success
  | { data: null; error: AppError[] }; // failure
 
// Constructors
export function errorOr<T>(data: T): ErrorOr<T>;
export function errorOr<T>(data: null, errors: AppError[]): ErrorOr<T>;

Usage in a service:

// Success
return errorOr(user);
 
// Failure
return errorOr<User>(null, [{ type: 'not_found', message: 'User not found' }]);

Usage in a controller:

const result = await service.getUser(id);
if (result.error) return serviceErrorResponse(result.error);
return successResponse(result.data);

AppError

// backend/types/appError.ts
export type AppErrorType =
  | 'not_found'
  | 'validation'
  | 'conflict'
  | 'unauthorized'
  | 'forbidden';
 
export type AppError = {
  type: AppErrorType;
  message: string;
  field?: string; // populated for field-level validation errors
};

Error Type → HTTP Status Code Mapping

serviceErrorResponse() in backend/utils/response/serviceErrorResponse.ts maps the first error’s type to an HTTP response:

AppErrorTypeHTTP StatusResponse helperWhen to use
not_found404notFoundError()Resource doesn’t exist
validation422validationErrorResponse()Input fails a business rule (used alongside field)
conflict422validationErrorResponse()Uniqueness violation (e.g. email already exists)
unauthorized401unauthorizedError()Authentication failed or session expired
forbidden403forbiddenError()Authenticated but insufficient role
(anything else)500new Response(500)Unexpected / unhandled error type

Response Shape

All responses use a consistent JSON envelope.

Success — successResponse(data, status?)

// status defaults to 200
export const successResponse = <T>(data: T, status = 200): Response =>
  Response.json({ data, status }, { status });
// 200 GET /api/user
{
  "data": [{ "id": "...", "email": "...", "name": "...", "role": "user" }],
  "status": 200
}

Error — ApiErrorResponse

// backend/types/apiErrorResponse.ts
export type ApiErrorResponse = {
  message: string;
  status: number;
  error: {
    type: AppErrorType;
    errors: FieldError[]; // populated for validation / conflict errors
    details?: string;
  };
};
// 401 — bad credentials
{
  "message": "Invalid email or password",
  "status": 401,
  "error": {
    "type": "unauthorized",
    "errors": []
  }
}
 
// 422 — validation failure
{
  "message": "Validation failed",
  "status": 422,
  "error": {
    "type": "validation",
    "errors": [
      { "field": "email", "message": "Invalid email address" }
    ]
  }
}

Request Validation

Incoming request bodies are validated with Zod via validateRequest():

// In a controller
const validation = await validateRequest(loginSchema, req);
if (validation.errors) {
  return validationErrorResponse('Validation failed', validation.errors);
}
// validation.data is fully typed from the schema
const { email, password } = validation.data;

validateRequest returns { data: T } on success or { errors: FieldError[] } on failure. FieldError shape: { field: string; message: string }.

Zod schemas live in backend/features/validation/schemas/.

URL parameters are validated with validateParam():

const validation = validateParam(uuidSchema, id);
if (validation.errors) {
  return validationErrorResponse('Validation failed', validation.errors);
}

Response Helper Reference

All helpers are exported from @backend/utils/response.

HelperStatusUse when
successResponse(data, status?)200+Operation succeeded
serviceErrorResponse(errors: AppError[])variesForwarding a service-layer ErrorOr error
validationErrorResponse(message, fieldErrors)422Request body fails Zod validation
notFoundError(message, details?)404Resource not found
unauthorizedError(message, details?)401Missing or invalid authentication
forbiddenError(message)403Authenticated but wrong role
tooManyRequestsError(message)429Rate limit exceeded

Error Handling in the Frontend

RTK Query surfaces errors as { isError: true, error: ApiErrorResponse }. The error.error.errors array contains field-level errors for form validation.

The baseQueryWithReauth interceptor handles 401s automatically — see Frontend State Management for the refresh flow.

Uncaught React rendering errors are caught by <ErrorBoundary> at the app root and optionally at section level.