ExisJS

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 (true for HttpError) 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 StatusError ClassException AliasDefault Error CodeStatic Factory Method
400BadRequestErrorBadRequestExceptionBAD_REQUESTHttpError.badRequest(msg, details?)
401UnauthorizedErrorUnauthorizedExceptionUNAUTHORIZEDHttpError.unauthorized(msg?)
403ForbiddenErrorForbiddenExceptionFORBIDDENHttpError.forbidden(msg?)
404NotFoundErrorNotFoundExceptionNOT_FOUNDHttpError.notFound(resource?)
409ConflictErrorConflictExceptionCONFLICTHttpError.conflict(msg)
413PayloadTooLargeErrorPayloadTooLargeExceptionPAYLOAD_TOO_LARGEHttpError.payloadTooLarge(msg?, details?)
422UnprocessableErrorUnprocessableExceptionUNPROCESSABLE_ENTITYHttpError.unprocessable(msg, details?)
429RateLimitErrorRateLimitExceptionRATE_LIMITEDHttpError.tooManyRequests(msg?)
500InternalErrorInternalExceptionINTERNAL_ERRORHttpError.internal(msg?)
503ServiceUnavailableErrorServiceUnavailableExceptionSERVICE_UNAVAILABLEHttpError.serviceUnavailable(msg?)
504GatewayTimeoutErrorGatewayTimeoutExceptionGATEWAY_TIMEOUTHttpError.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

src/http/users/route.ts
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

src/http/users/route.ts
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:

HTTP 404 Response
{  "success": false,  "statusCode": 404,  "error": {    "code": "NOT_FOUND",    "message": "User not found"  }}
FieldDescription
successAlways false for errors
statusCodeSame as the HTTP status
error.codeStable machine-readable code: NOT_FOUND, UNAUTHORIZED, VALIDATION_ERROR, INTERNAL_ERROR...
error.messageHuman-readable message. For unexpected errors in production it is a generic message; internals are never exposed
error.detailsOptional 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):

HTTP 400 Validation Error Response
{  "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:

exis.config.ts
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

src/http/error.ts
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

src/http/error.ts
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:

src/http/server.ts
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():

src/http/filters/database.filter.ts
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:

src/http/users/route.ts
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

src/http/billing/route.ts
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 generic 500 Internal Server Error without 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.