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:
AppErrorType | HTTP Status | Response helper | When to use |
|---|---|---|---|
not_found | 404 | notFoundError() | Resource doesn’t exist |
validation | 422 | validationErrorResponse() | Input fails a business rule (used alongside field) |
conflict | 422 | validationErrorResponse() | Uniqueness violation (e.g. email already exists) |
unauthorized | 401 | unauthorizedError() | Authentication failed or session expired |
forbidden | 403 | forbiddenError() | Authenticated but insufficient role |
| (anything else) | 500 | new 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.
| Helper | Status | Use when |
|---|---|---|
successResponse(data, status?) | 200+ | Operation succeeded |
serviceErrorResponse(errors: AppError[]) | varies | Forwarding a service-layer ErrorOr error |
validationErrorResponse(message, fieldErrors) | 422 | Request body fails Zod validation |
notFoundError(message, details?) | 404 | Resource not found |
unauthorizedError(message, details?) | 401 | Missing or invalid authentication |
forbiddenError(message) | 403 | Authenticated but wrong role |
tooManyRequestsError(message) | 429 | Rate 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.