The problem with ad hoc errors
Endpoint A returns { error: "Not found" }. Endpoint B returns { message: "Not found", code: 404 }. Endpoint C throws and lets the framework return { statusCode: 500, message: "Internal server error" }. The frontend has to handle three different shapes.
A consistent error envelope
Every error response from our API looks like this:
interface ApiError {
statusCode: number;
code: string; // machine-readable: 'RESOURCE_NOT_FOUND'
message: string; // human-readable: 'The requested user was not found'
details?: unknown; // validation errors, debug info, etc.
}
Custom exception classes
export class AppException extends HttpException {
constructor(
public readonly code: string,
message: string,
statusCode: number,
public readonly details?: unknown,
) {
super({ statusCode, code, message, details }, statusCode);
}
}
export class NotFoundException extends AppException {
constructor(resource: string, id: string) {
super(
'RESOURCE_NOT_FOUND',
`${resource} with id ${id} was not found`,
404,
);
}
}
export class ValidationException extends AppException {
constructor(errors: Record<string, string[]>) {
super('VALIDATION_ERROR', 'Request validation failed', 422, errors);
}
}
Global exception filter
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
if (exception instanceof AppException) {
return response.status(exception.getStatus()).json({
statusCode: exception.getStatus(),
code: exception.code,
message: exception.message,
details: exception.details,
});
}
// Unexpected errors — log and return generic 500
console.error('Unhandled exception:', exception);
return response.status(500).json({
statusCode: 500,
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
});
}
}
Why this matters for clients
The frontend can switch on code instead of parsing message strings. Adding a new error type never breaks existing error handling. And the details field carries structured validation errors that can be mapped directly to form fields.
Logging and observability
The global filter is the perfect place to log errors with context: request ID, user ID, endpoint, duration. We send all 5xx errors to our alerting pipeline and sample 4xx errors for analytics. The structured code field makes it easy to aggregate errors by type.
