Error Handling
ExisJS provides an integrated exception architecture designed to standardize HTTP error responses, handle validation failures, and safely catch uncaught runtime exceptions across both functional and class-based applications.
The HttpError Hierarchy
All HTTP-related exceptions in ExisJS inherit from the base HttpError class. When an HttpError is thrown within a route handler or middleware, the router intercepts it and formats a consistent JSON error response with appropriate HTTP status codes and headers.
Base HttpError Class
export class HttpError extends Error { public readonly statusCode: number public readonly code: string public readonly isOperational: boolean public readonly details?: unknown
constructor( message: string, statusCode = 500, code = 'INTERNAL_ERROR', details?: unknown )}statusCode: The HTTP status code returned to the client (e.g.,404,401,500).code: A machine-readable uppercase string identifying the error category (e.g.,'NOT_FOUND','BAD_REQUEST').isOperational: A boolean flag (trueforHttpError) indicating expected operational errors versus programmer bugs or unhandled runtime crashes.details: Optional structured context or validation metadata attached to the error.
Standard Error Classes & Exception Aliases
ExisJS exports dedicated error classes alongside standard *Exception aliases for compatibility with both idiomatic TypeScript and NestJS-style OOP architectures.
| HTTP Status | Error Class | Exception Alias | Default Error Code | Static Factory Method |
|---|---|---|---|---|
400 | BadRequestError | BadRequestException | BAD_REQUEST | HttpError.badRequest(msg, details?) |
401 | UnauthorizedError | UnauthorizedException | UNAUTHORIZED | HttpError.unauthorized(msg?) |
403 | ForbiddenError | ForbiddenException | FORBIDDEN | HttpError.forbidden(msg?) |
404 | NotFoundError | NotFoundException | NOT_FOUND | HttpError.notFound(resource?) |
409 | ConflictError | ConflictException | CONFLICT | HttpError.conflict(msg) |
413 | PayloadTooLargeError | PayloadTooLargeException | PAYLOAD_TOO_LARGE | HttpError.payloadTooLarge(msg?, details?) |
422 | UnprocessableError | UnprocessableException | UNPROCESSABLE_ENTITY | HttpError.unprocessable(msg, details?) |
429 | RateLimitError | RateLimitException | RATE_LIMITED | HttpError.tooManyRequests(msg?) |
500 | InternalError | InternalException | INTERNAL_ERROR | HttpError.internal(msg?) |
503 | ServiceUnavailableError | ServiceUnavailableException | SERVICE_UNAVAILABLE | HttpError.serviceUnavailable(msg?) |
504 | GatewayTimeoutError | GatewayTimeoutException | GATEWAY_TIMEOUT | HttpError.gatewayTimeout(msg?) |
Throwing Errors in Handlers
You can throw errors directly in controllers, route definitions, or services using either static factory methods, direct instantiation of error classes, or exception aliases.
Functional Paradigm
import { controller, route } from 'exisjs/router'import { HttpError, NotFoundError, ForbiddenException } from 'exisjs/error'
export default controller({ getUser: route.get('/:id', { async handle({ params }) { const user = await db.users.findById(params.id) if (!user) { // Option 1: Static factory helper throw HttpError.notFound('User') } if (user.isSuspended) { // Option 2: Direct exception alias throw new ForbiddenException('User account has been suspended') }
return { user } } })})Class-Based (OOP) Paradigm
import { Controller, Get, Param } from 'exisjs/decorators'import { NotFoundException, ForbiddenException } from 'exisjs/error'
@Controller('/users')export default class UsersController { @Get('/:id') async getUser(@Param('id') id: string) { const user = await db.users.findById(id) if (!user) { throw new NotFoundException('User') } if (user.isSuspended) { throw new ForbiddenException('User account has been suspended') }
return { user } }}Standard Error Response Format
Every error response has the same core shape, whatever caused it:
{ "success": false, "statusCode": 404, "error": { "code": "NOT_FOUND", "message": "User not found" }}| Field | Description |
|---|---|
success | Always false for errors |
statusCode | Same as the HTTP status |
error.code | Stable machine-readable code: NOT_FOUND, UNAUTHORIZED, VALIDATION_ERROR, INTERNAL_ERROR... |
error.message | Human-readable message. For unexpected errors in production it is a generic message; internals are never exposed |
error.details | Optional extra data (validation issues, or the details passed to an HttpError). Never copied to the top level |
Clients can rely on error.code and error.message for every failed request: thrown errors, validation, @Roles / @Permissions / guard rejections, 404 and 405 all use this shape. Codes the framework itself sends include VALIDATION_ERROR, INVALID_JSON, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, METHOD_NOT_ALLOWED (with an Allow header), RATE_LIMIT_EXCEEDED, IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_IN_PROGRESS and INTERNAL_ERROR. A handler that rejects with null or undefined is a 500, never a 404.
Validation Error Responses
When schema validation fails (via tex, Zod, Yup or a DTO's validate()), the response is a 400 with one entry per failing field in error.details (up to 100):
{ "success": false, "statusCode": 400, "error": { "code": "VALIDATION_ERROR", "message": "Validation Error", "details": [ { "field": "email", "message": "Must be a valid email address", "expected": "valid email address", "received": "\"invalid-email\"", "code": "INVALID_EMAIL", "httpPart": "body" }, { "field": "age", "message": "Must be at least 18", "expected": "must be at least 18", "received": "16", "code": "VALUE_TOO_SMALL", "httpPart": "body" } ] }}received never shows secrets: credential fields (password, token, secret, apiKey...) and connection settings (DATABASE_URL, REDIS_URI, SENTRY_DSN, *_CONNECTION...) are [Redacted], and the password of any scheme://user:password@host value is masked as ***, in responses, logs and tex.env startup errors.
Status codes. Malformed input that cannot be parsed at all is 400 (INVALID_JSON for a broken JSON body); input that parses but breaks the schema is 400 VALIDATION_ERROR. Use HttpError.unprocessable() (422) for business rules that the schema cannot express, such as "this email is already registered".
Pre-0.9 duplicate fields
Before 0.9, error bodies also carried message, validationErrors, errors, details and timestamp at the top level. They are gone by default. Clients that still read them can get them back while they migrate:
export default { errors: { legacyFields: true }, // deprecated, removed in 1.0}Development Terminal Diagnostics
In development mode (NODE_ENV !== 'production'), validation failures output a formatted diagnostic table to the console identifying the failed HTTP part (body, query, params, headers), field names, received values, and expected criteria:
[Validation Failed] POST /auth/register (part: body) ✖ email: Invalid email format ✖ age: Minimum age is 18┌─────────────┬───────────────────────────┬──────────────────────────┐│ Field │ Received │ Expected │├─────────────┼───────────────────────────┼──────────────────────────┤│ email │ "invalid-email" │ Valid email address ││ age │ "16" │ >= 18 │└─────────────┴───────────────────────────┴──────────────────────────┘Centralized Exception Handling (src/http/error.ts)
ExisJS supports an application-wide error handler convention located at src/http/error.ts. When this file is present, the framework routes all unhandled exceptions, validation errors, and uncaught rejections through this handler before sending the final HTTP response.
Functional Paradigm
import type { ExisRequest, ExisResponse } from 'exisjs'
/** * Global application error handler. * Automatically mounted during server bootstrap. */export function onError(err: any, req: ExisRequest, res: ExisResponse) { const status = err.statusCode || err.status || 500 const isOperational = Boolean(err.isOperational || status < 500)
// Log non-operational server crashes to telemetry / APM if (!isOperational && process.env.NODE_ENV === 'production') { req.log.error({ err, path: req.path, method: req.method }, 'Unhandled server crash') }
res.status(status).json({ success: false, statusCode: status, error: { code: err.code || (status === 500 ? 'INTERNAL_SERVER_ERROR' : 'ERROR'), message: err.message || 'An unexpected error occurred', ...(err.details ? { details: err.details } : {}), ...(err.errors ? { errors: err.errors } : {}), path: req.path, timestamp: new Date().toISOString(), }, })}Class-Based (OOP) Paradigm
import type { ExisRequest, ExisResponse } from 'exisjs'
export default class GlobalErrorHandler { onError(err: any, req: ExisRequest, res: ExisResponse) { const status = err.statusCode || err.status || 500 const isOperational = Boolean(err.isOperational || status < 500)
if (!isOperational && process.env.NODE_ENV === 'production') { req.log.error({ err, path: req.path, method: req.method }, 'Unhandled server crash') }
res.status(status).json({ success: false, statusCode: status, error: { code: err.code || (status === 500 ? 'INTERNAL_SERVER_ERROR' : 'ERROR'), message: err.message || 'An unexpected error occurred', ...(err.details ? { details: err.details } : {}), path: req.path, timestamp: new Date().toISOString(), }, }) }}Exception Filters (@Catch & catchError)
Exception filters allow you to bind error-handling logic to specific exception types, preventing repetitive try/catch boilerplate across service layers.
Middleware-Based Exception Filter (catchError)
Use the catchError middleware to intercept specific error classes at the server or boundary level:
import { exis } from 'exisjs'import { catchError } from 'exisjs/middleware'
export class DatabaseTimeoutError extends Error { constructor(message = 'Database query timed out') { super(message) this.name = 'DatabaseTimeoutError' }}
export default exis({ async onStart(app) { // Intercept DatabaseTimeoutError globally and map to a 503 response app.use(catchError(DatabaseTimeoutError, (err, req, res) => { res.status(503).json({ success: false, statusCode: 503, error: { code: 'DATABASE_UNAVAILABLE', message: err.message, }, }) })) }})OOP Exception Filters (@Catch & ExceptionFilter)
In class-based applications, implement the ExceptionFilter<T> interface and bind it using @UseFilters() or @Use():
import { Catch, type ExceptionFilter, type ArgumentsHost } from 'exisjs/decorators'import { DatabaseTimeoutError } from '../errors/database-timeout.error'
@Catch(DatabaseTimeoutError)export class DatabaseExceptionFilter implements ExceptionFilter<DatabaseTimeoutError> { catch(exception: DatabaseTimeoutError, host: ArgumentsHost) { const { res } = host res.status(503).json({ success: false, statusCode: 503, error: { code: 'DATABASE_TIMEOUT', message: exception.message, }, timestamp: new Date().toISOString(), }) }}Attach the filter to a controller or specific route method:
import { Controller, Get, UseFilters } from 'exisjs/decorators'import { DatabaseExceptionFilter } from '../filters/database.filter'
@Controller('/users')@UseFilters(DatabaseExceptionFilter)export default class UsersController { @Get('/metrics') async getMetrics() { // If this throws DatabaseTimeoutError, DatabaseExceptionFilter handles it return await db.queryMetrics() }}Local Controller Error Handlers (onError)
For localized error boundaries that apply only to routes within a single controller, define an onError hook directly on the controller.
Functional Paradigm
import { controller, route } from 'exisjs/router'
export default controller({ // Intercepts all errors thrown by routes in this controller onError(err, req, res) { req.log.warn({ err }, 'Billing operation failed') res.status(402).json({ success: false, statusCode: 402, error: { code: 'PAYMENT_REQUIRED', message: err.message, }, }) },
checkout: route.post('/checkout', { async handle({ body }) { throw new Error('Insufficient funds') } })})Unhandled Errors in Production vs Development
ExisJS handles uncaught non-operational errors (such as unexpected TypeError or unhandled promise rejections) based on NODE_ENV:
- Production (
NODE_ENV === 'production'): Returns a generic500 Internal Server Errorwithout leaking stack traces, internal variables, or system paths. The complete stack trace is written to the structured logger. - Development (
NODE_ENV !== 'production'):- For JSON clients (
Accept: application/json): Returns the error message and complete stack trace in the JSON envelope. - For browser clients (
Accept: text/html): Renders an interactive diagnostic error overlay with visual code frames and file locations.
- For JSON clients (