Skip to content
HipraxPublic

About

A modular error handling solution for Express.js applications.

Resources

Security policy

Stars

2 stars

Watchers

1 watching

Forks

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.
  • If reading err.code throws (a code getter that throws, or a revoked Proxy passed as the error), the error is treated as having no code. If listing the duplicate-key fields from err.keyValue throws for a 11000 error (a throwing getter, or a revoked Proxy as keyValue), the field list reads unknown and the response is still the 400 duplicate-key error. Either way the client gets the JSON payload instead of Express's HTML error page, and with res.headersSent the original error is still passed to next.
  • If reading err.status throws, the error is treated as having no status (see the default row of handleCommonErrors), so it still answers 500 with its own message.
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.

Redaction follows the resolved status. Only a response whose final status is 500 or above is redacted, after the status has been resolved from err.statusCode / err.status (see handleCommonErrors) and from the err.code mappings. An error that carries a known 4xx status (one in the built-in status table) and no numeric statusCode therefore answers with its 4xx status and its own message, exactly like an error with the same numeric statusCode. For HTTP-client SDK errors such as Octokit's RequestError (whose name is "HttpError"), that message can include text from the upstream response body. In 0.7.1 and earlier such an error resolved to 500, so its message was redacted. A 5xx status is still 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, else err.status, else 500 Passes through err.message if present (otherwise "Unhandled server error"). Only a numeric value counts, and a code outside the known 4xx/5xx table becomes 500

Status resolution for other errors. For an error whose name is not in the table, a numeric err.statusCode wins, and err.status is then not read at all. Without one, a numeric err.status is used, following Express's own convention (the response status comes from err.status or err.statusCode). The precedence differs from Express's default error handler, which checks err.status first; here statusCode wins so that every error with a numeric statusCode resolves exactly as before. This is what makes express-jwt's UnauthorizedError (status 401) and passport's AuthenticationError (status, 401 by default), which set no statusCode, answer with that status instead of 500. HTTP-client SDK errors that carry the upstream status as a numeric status, such as Octokit's RequestError (whose name is "HttpError"), pass that status through too, just as AxiosError upstream statuses do. A non-numeric status (for example the string "401"), a code outside the table (for example 299), or a status getter that throws resolves to 500.

Cause chain. Every mapper branch above (and every err.code mapping in errorMiddleware — ENOENT, 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 for const w = catchAsync(fn), catchAsync(w) === w, and re-applying the decorator does not double-wrap methods. Only the value itself counts as already wrapped: a subclass of a wrapped class, or a function whose [[Prototype]] is a wrapped handler, does not, so passing it to catchAsync wraps it.

  • 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. Decorating a subclass of a class that is itself decorated works the same way: the subclass's own methods are wrapped, and the methods it inherits from the decorated parent are copied onto its prototype by reference, reusing the parent's wrappers rather than wrapping them twice.

  • Any thenable result is adopted into a native Promise. When a handler returns a value with a then method, the wrapper adopts it with Promise.resolve(...) and returns a native Promise that resolves to the handler's value, or to undefined when it rejects (the rejection goes to next, unless next was already called). Only then is required: a PromiseLike without catch works, a then that throws counts as a rejection, and a thenable's own catch is never called. Express 5's router therefore receives a native promise for every thenable result and does not log its "handlers that are Promise-like are deprecated" warning. An instance of a Promise subclass comes back as a plain Promise, not as the subclass.

  • Non-Error throws are wrapped. A thrown or rejected value that is not an Error reaches next as an Error whose message is Non-Error thrown/rejected: <value> (async rejection) or Non-Error thrown synchronously: <value> (synchronous throw), where <value> is String(value). When that conversion itself throws, because String() fails (a null-prototype object such as Object.create(null), an object whose toString throws) or instanceof Error fails (a revoked Proxy, a Proxy whose getPrototypeOf trap throws), <value> is [unprintable <typeof>] instead, for example Non-Error thrown/rejected: [unprintable object], so the error still reaches next rather than escaping the wrapper. Error instances are forwarded as themselves.

  • 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:coverage # the same suite with coverage, gated at 100%
npm run test:types    # compile-time assertions over the type signatures in src/

npm test runs the Jest suite. npm run test:coverage runs it with coverage and fails if statements, branches, functions or lines drop below 100% (the coverageThreshold in jest.config.js); CI runs the suite with coverage on every Node version in its matrix, so the same floor applies there.

The suite includes integration tests that go through a real Express app with supertest, with nothing faked. tests/catchAsync.integration.test.ts runs its behavior cases against both supported majors, Express 4 (installed as the express4 development alias) and Express 5, plus one Express 5-only check that the router receives a native promise; a harness test checks that the two really are different majors. tests/errorMiddleware.integration.test.ts runs against Express 5.

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