Skip to content

Repository files navigation

@hiprax/errors

npm version license CI codecov CodeQL npm provenance

A small, typed error toolkit for Express.js apps. Zero runtime dependencies.

  • Custom Error class with statusCode and statusText
  • Production-ready error middleware for Express
  • Common error mapper for popular libraries (Mongoose, JWT, Axios, Zod, etc.)
  • Async wrapper & class decorator catchAsync for safe handlers and controllers
  • HTTP error factories for concise, consistent error creation
  • TypeScript first with ESM + CJS builds and .d.ts types

Install

npm install @hiprax/errors

Requires Node >= 18.12 and Express >= 4.x (peer dependency).

Quick Start

import express from "express";
import {
  errorMiddleware,
  catchAsync,
  httpErrors,
} from "@hiprax/errors";

const app = express();

app.get(
  "/users/:id",
  catchAsync(async (req, res) => {
    if (req.params.id === "0") {
      throw httpErrors.notFound("User not found");
    }
    res.json({ id: req.params.id });
  })
);

// Always register last
app.use(errorMiddleware);

API

ErrorHandler

new ErrorHandler(message?: string, statusCode?: number, options?: { cause?: unknown })

Custom Error subclass with HTTP semantics.

Parameter Default Description
message "Something went wrong! Please try again" Error message
statusCode 500 HTTP status code (unknown codes normalize to 500)
options undefined ES2022 options bag — pass { cause: originalError } to preserve the underlying error on this.cause.

The instance exposes .statusCode and .statusText (resolved from the built-in status code map).

import { ErrorHandler } from "@hiprax/errors";

throw new ErrorHandler("Not allowed", 403);
// => { message: "Not allowed", statusCode: 403, statusText: "Forbidden" }

// Preserve the underlying error for richer logs / chained debugging
try {
  await db.query("SELECT 1");
} catch (err) {
  throw new ErrorHandler("Lookup failed", 500, { cause: err });
  // => err.cause === the original db error
}

errorMiddleware

errorMiddleware(err, req, res, next)

Express error middleware. Register it as the last middleware in your app.

Processing pipeline:

  1. Normalizes the error via handleCommonErrors (by err.name)

  2. Maps well-known err.code values:

    err.code Status Message
    "ENOENT" 404 Resource not found
    11000 (Mongo dup key) 400 Duplicate entry for field(s): ...
    "EBADCSRFTOKEN" 403 Invalid CSRF token
    "ECONNREFUSED" / "ECONNRESET" / "ETIMEDOUT" 502 Upstream network error
  3. Checks res.headersSent and, if so, delegates to Express's default error handler (next(err)) so the in-flight response is finalized cleanly.

  4. Responds with JSON:

{
  "success": false,
  "message": "...",
  "statusCode": 400,
  "statusText": "Bad Request",
  "stack": "..."
}

stack is only included when NODE_ENV !== "production". When present, it is truncated to a bounded length so a pathologically large trace cannot bloat the response.

Hardening behavior. The middleware is defensive against hostile or malformed errors:

  • If err.message or err.stack is backed by a getter that throws, the middleware substitutes a safe fallback string instead of crashing.
  • If the JSON payload fails to serialize (circular references, BigInt, functions, symbols, etc.), the middleware retries with a sanitizing replacer that strips/normalizes the offending values.
  • If JSON serialization fails even after sanitization, it falls back to a plain-text response (text/plain) using the resolved status text so the client always receives some response.
  • If the mapper itself throws while normalizing the error, the middleware degrades to a generic 500 rather than letting the throw escape.
app.use(errorMiddleware);

createErrorMiddleware(options)

errorMiddleware is the zero-config form of a factory. Call createErrorMiddleware(options) to build a middleware with the same pipeline plus opt-in response hardening. createErrorMiddleware() with no options is identical to errorMiddleware.

Option Default Description
exposeServerErrors true When false, server-error (statusCode >= 500) messages are replaced with the generic status text (e.g. "Internal Server Error") in production only (NODE_ENV === "production"), so internal details are not leaked to clients. Client errors (4xx) and non-production responses are unaffected, and the original error stays reachable to structured loggers via err.cause.
import { createErrorMiddleware } from "@hiprax/errors";

// Redact 5xx messages to the generic status text in production
app.use(createErrorMiddleware({ exposeServerErrors: false }));

Like the stack field, this redaction is production-gated: in development/test the real 5xx message is still shown so you can debug, and 4xx messages are never redacted.


handleCommonErrors

handleCommonErrors(err: any): ErrorHandler

Maps common library/framework errors to ErrorHandler instances by err.name:

err.name Status Behavior
CastError 400 Includes err.path in message (Mongoose)
ValidationError 400 Joins all err.errors[*].message (Mongoose)
JsonWebTokenError 401 Fixed JWT invalid/expired message
TokenExpiredError 401 Fixed JWT invalid/expired message
NotBeforeError 401 JWT used before its nbf ("not before") timestamp; same message as the other JWT cases
AxiosError upstream status, else 502 When err.response.status is a known HTTP error code, that status passes through (e.g. upstream 404 → 404). Otherwise falls back to status 502 with message "Error communicating with an external service".
SyntaxError 400 Malformed JSON or invalid syntax
AggregateError 500 Joins err.errors[*].message with ; ; original AggregateError attached as cause for full chain traversal
ZodError 400 Joins err.issues[*].message (Zod)
(default) err.statusCode or 500 Passes through err.message if present

Cause chain. Every mapper branch above (and every err.code mapping in errorMiddlewareENOENT, 11000, EBADCSRFTOKEN, ECONNREFUSED / ECONNRESET / ETIMEDOUT) preserves the original error on .cause. Structured loggers (Pino's err serializer, Sentry, Node 22+ console.error, OpenTelemetry's exception.cause) walk this chain to surface the underlying library-specific error (e.g. the original Mongoose validation, Axios response.data, Zod issues[*].path). The JSON response body intentionally omits cause to avoid leaking upstream payloads — read it from the live Error instance via your logger of choice.


catchAsync

// The constraints are deliberately the loosest parameterizations, so any
// `Request<P, ...>` and any interface extending `Request` is accepted.
type ReqLike = Request<any, any, any, any, any>;
type ResLike = Response<any, any>;

// request handler: 0-3 declared parameters in, always 3 out
catchAsync<Req extends ReqLike = Request, Res extends ResLike = Response, R = unknown>(
  fn: (req: Req, res: Res, next: NextFunction) => R
): (req: Req, res: Res, next: NextFunction) => WrappedResult<R>;

// error handler: declare all 4 parameters. Note `Err` comes AFTER `Req`/`Res`,
// so a single explicit type argument binds to `Req` and is rejected cleanly
// rather than silently mistyping the remaining parameters.
catchAsync<Req extends ReqLike = Request, Res extends ResLike = Response, Err = any, R = unknown>(
  fn: (err: Err, req: Req, res: Res, next: NextFunction) => R
): (err: Err, req: Req, res: Res, next: NextFunction) => WrappedResult<R>;

// class decorator, and the manual `catchAsync(MyClass)` call site
catchAsync<T extends new (...args: any[]) => any>(constructor: T): T;
catchAsync<T extends new (...args: any[]) => any>(value: T, context: ClassDecoratorContext): T;

Dual-purpose utility:

  • Function wrapper — wraps a single handler so thrown/rejected errors are forwarded to next(). Prevents duplicate next() calls. Normalizes function arity to what Express needs and preserves the function's name.
  • Class decorator — wraps all prototype methods (including inherited) of an Express controller class.

The wrapper returns a handler that declares all three parameters even when the handler you pass in declares fewer, because the wrapper reads next from the last positional argument on every branch. Request and Response type arguments survive the wrapping, so typed route params keep working:

import type { Request, Response } from "express";

router.get(
  "/posts/:id",
  catchAsync(async (req: Request<{ id: string }>, res: Response) => {
    const post = await getPost(req.params.id); //         ^? string
    res.json(post);
  })
);
// Function wrapper
router.get(
  "/posts",
  catchAsync(async (req, res) => {
    const posts = await listPosts();
    res.json(posts);
  })
);

// Class decorator (works with both legacy and stage-3 decorators)
@catchAsync
class UserController {
  async getUser(req: Request, res: Response) {
    res.json({ id: req.params.id });
  }
}

// Manual application is also supported and behaves identically
class OrderController {
  async list(req: Request, res: Response) {
    res.json(await loadOrders());
  }
}
const WrappedOrderController = catchAsync(OrderController);

Decorator setup

catchAsync supports both decorator implementations and you can pick whichever your project uses:

  • Stage-3 (TC39) decorators — TypeScript 5.0+ default. No special flag needed. Works out of the box when experimentalDecorators is false or omitted.

  • Legacy / experimental decorators — set in tsconfig.json:

    {
      "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": false
      }
    }

Both forms wrap every method on the class prototype (and inherited ones, shadowed on the decorated subclass without mutating parents).

Caveats

  • Manual call footgun. You can write const Wrapped = catchAsync(MyClass) and export Wrapped instead of decorating in place. This works and is idempotent — wrapping an already-wrapped class or function returns the same value, so catchAsync(catchAsync(fn)) === catchAsync(fn) and re-applying the decorator does not double-wrap methods.

  • Manual invocation requires next. Wrapped handlers rely on the last positional argument being Express's next callback. If it is missing or is not a function, the wrapper returns undefined and never calls your handler at all. Calling a wrapped request handler with two arguments is now a compile error, so the type system catches this; a wrapped controller method invoked directly (e.g. controller.method(req, res)) can still hit it via the prototype, so in unit tests either pass a jest.fn() as next or call the original undecorated function.

  • The class decorator does not fix method arity at the type level. Decorated prototype methods keep the signatures you declared, while at runtime each is replaced by a 3- or 4-parameter wrapper that reads next from the last positional argument. So for async getUser(req, res), calling controller.getUser(req, res, next) is a compile error even though it is the only call that works, and controller.getUser(req, res) compiles while being a no-op that never runs the body. This cannot be fixed without breaking the decorator itself: a class decorator must return a type assignable to the class it decorates under both dialects, and a method type with more parameters is not assignable to one with fewer. If you need call-site type safety, wrap the handlers individually with catchAsync(fn) instead of decorating the class. Handing decorated methods straight to Express is unaffected, since Express always supplies next.

  • Inheritance is isolated. Decorating a subclass shadows inherited methods on the subclass's own prototype — it does not mutate the parent class. Sibling subclasses and direct uses of the parent class continue to use the original unwrapped methods.

  • Express's arity contract. Express identifies error-handling middleware by function arity: length === 4 means error handler, length === 3 means request handler. catchAsync normalizes arity rather than preserving it: a source declaring 4 or more parameters yields a 4-parameter wrapper, and everything else yields a 3-parameter wrapper. So app.use(catchAsync(myErrorHandler)) registers correctly as long as the handler declares all four parameters. A three-parameter (err, req, res) handler wraps to arity 3 and Express will route it as a request handler; that is equally true without catchAsync, so declare all four.

  • Unannotated error handlers need annotations. catchAsync((err, req, res, next) => ...) with no parameter types resolves them to implicit any, because TypeScript can draw a contextual signature from only one overload and that is the request-handler form. Express has the same limitation on its own app.use. Annotate the parameters and it resolves.


httpErrors

import { httpErrors } from "@hiprax/errors";

Namespaced factory functions that return ErrorHandler instances. Each factory has the signature (message?: string, options?: { cause?: unknown }) => ErrorHandler, so you can override the default message and/or attach an underlying cause.

Factory Code Default Message
httpErrors.badRequest 400 Bad request
httpErrors.unauthorized 401 Unauthorized
httpErrors.forbidden 403 Forbidden
httpErrors.notFound 404 Not found
httpErrors.methodNotAllowed 405 Method not allowed
httpErrors.requestTimeout 408 Request timeout
httpErrors.conflict 409 Conflict
httpErrors.gone 410 Gone
httpErrors.payloadTooLarge 413 Payload too large
httpErrors.unsupportedMediaType 415 Unsupported media type
httpErrors.unprocessableEntity 422 Unprocessable entity
httpErrors.tooManyRequests 429 Too many requests
httpErrors.internalServerError 500 Internal server error
httpErrors.notImplemented 501 Not implemented
httpErrors.badGateway 502 Bad gateway
httpErrors.serviceUnavailable 503 Service unavailable
httpErrors.gatewayTimeout 504 Gateway timeout
throw httpErrors.notFound();                          // "Not found" (404)
throw httpErrors.forbidden("Admins only");            // "Admins only" (403)
throw httpErrors.conflict("Email taken", { cause: dbErr }); // 409, cause preserved

errorCodes

import { errorCodes } from "@hiprax/errors";

A ReadonlyMap<number, string> of all standard HTTP 4xx/5xx status codes and their text descriptions. .set, .delete, and .clear throw TypeError — the map is sealed against mutation. Used internally by ErrorHandler to validate codes and resolve statusText. Exported for advanced use cases (e.g., custom middleware or logging).

errorCodes.get(404); // "Not Found"
errorCodes.get(418); // "I'm a teapot"

Exported types

The package re-exports the following types from its entry point so consumers can statically type wrappers, response parsers, and custom factories without redeclaring them locally:

Type Source module Purpose
ErrorHandler ./ErrorHandler The custom Error subclass itself (also usable as a value via import { ErrorHandler }).
ErrorHandlerOptions ./ErrorHandler Options bag for the ErrorHandler constructor — currently { cause?: unknown }, mirrors ES2022.
ErrorPayload ./errorMiddleware The JSON shape produced by errorMiddleware ({ success: false, message, statusCode, statusText, stack? }).
ErrorMiddlewareOptions ./errorMiddleware Options bag for createErrorMiddleware — currently { exposeServerErrors?: boolean }.
ErrorFactory ./httpErrors The signature shared by every httpErrors.* factory: (message?, options?) => ErrorHandler.
AsyncRequestHandler ./catchAsync The request-handler shape catchAsync accepts: (req, res, next) => R, with Req/Res/R parameterizable.
AsyncErrorRequestHandler ./catchAsync The error-handler shape catchAsync accepts: (err, req, res, next) => R. Type parameters are <Req, Res, Err, R>; Err is third, not first.
WrappedResult ./catchAsync What a wrapped handler returns: Promise<Awaited<R> | undefined> for async handlers, R | undefined otherwise.
import {
  ErrorHandler,
  type ErrorHandlerOptions,
  type ErrorPayload,
  type ErrorFactory,
} from "@hiprax/errors";

// Build your own factory with the same signature shape
const teapot: ErrorFactory = (message = "I'm a teapot", options) =>
  new ErrorHandler(message, 418, options);

// Type a fetch wrapper response
async function call(url: string): Promise<unknown | ErrorPayload> {
  const r = await fetch(url);
  return r.json();
}

TypeScript & Builds

  • ESM (.mjs) and CJS (.js) builds via an exports map
  • Full .d.ts type declarations
  • sideEffects: false for optimal tree-shaking

Testing

npm test           # Jest suite covering all modules
npm run test:types # compile-time assertions over the type signatures in src/

npm test runs the Jest suite. npm run test:types type-checks the whole tests/ tree, including tests/types/*.test-d.ts, which asserts things no runtime test can observe: that a wrapped handler reports the arity it actually has, that Request type arguments survive wrapping, and that invalid inputs stay rejected. It is a separate gate because tsconfig.json sets isolatedModules: true, which puts ts-jest into transpile-only mode, so npm test reports no type errors.

These assertions run against src/. The emitted declarations in dist/ are covered separately by npm run check-types-pack (attw --pack .), which verifies they resolve cleanly in all four module-resolution modes.

Contributing

Issues and PRs are welcome. Please include tests and keep the API surface small and focused.

Security

Security vulnerabilities should be reported privately via GitHub private advisories or by email — see SECURITY.md for the full policy, supported versions, and response timeline. Please do not open public GitHub issues for security problems.

License

MIT © Hiprax

About

A modular error handling solution for Express.js applications.

Resources

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages