@hiprax/errors
v0.5.6
Published
A modular error handling solution for Express.js applications.
Maintainers
Readme
@hiprax/errors
A small, typed error toolkit for Express.js apps. Zero runtime dependencies.
- Custom Error class with
statusCodeandstatusText - Production-ready error middleware for Express
- Common error mapper for popular libraries (Mongoose, JWT, Axios, Zod, etc.)
- Async wrapper & class decorator
catchAsyncfor safe handlers and controllers - HTTP error factories for concise, consistent error creation
- TypeScript first with ESM + CJS builds and
.d.tstypes
Install
npm install @hiprax/errorsRequires 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:
Normalizes the error via
handleCommonErrors(byerr.name)Maps well-known
err.codevalues:|
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 |Checks
res.headersSentand, if so, delegates to Express's default error handler (next(err)) so the in-flight response is finalized cleanly.Responds with JSON:
{
"success": false,
"message": "...",
"statusCode": 400,
"statusText": "Bad Request",
"stack": "..."
}
stackis only included whenNODE_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.messageorerr.stackis 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);handleCommonErrors
handleCommonErrors(err: any): ErrorHandlerMaps 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 502 "Bad gateway". |
| 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.codemapping inerrorMiddleware—ENOENT,11000,EBADCSRFTOKEN,ECONNREFUSED/ECONNRESET/ETIMEDOUT) preserves the original error on.cause. Structured loggers (Pino'serrserializer, Sentry, Node 22+console.error, OpenTelemetry'sexception.cause) walk this chain to surface the underlying library-specific error (e.g. the original Mongoose validation, Axiosresponse.data, Zodissues[*].path). The JSON response body intentionally omitscauseto avoid leaking upstream payloads — read it from the liveErrorinstance via your logger of choice.
catchAsync
catchAsync(fn): wrappedFn
catchAsync(Class): Class // as decoratorDual-purpose utility:
- Function wrapper — wraps a single handler so thrown/rejected errors are forwarded to
next(). Prevents duplicatenext()calls. Preserves function arity and name for correct Express routing. - Class decorator — wraps all prototype methods (including inherited) of an Express controller class.
// 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
experimentalDecoratorsisfalseor 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 exportWrappedinstead of decorating in place. This works and is idempotent — wrapping an already-wrapped class or function returns the same value, socatchAsync(catchAsync(fn)) === catchAsync(fn)and re-applying the decorator does not double-wrap methods.Manual invocation outside Express returns
undefined. Wrapped handlers rely on the last positional argument being Express'snextcallback. If you call a wrapped controller method directly from your own code (e.g. in a unit test or from a script) without supplying anext-shaped function, thrown errors are forwarded to a no-op and silently swallowed. For unit tests, either pass ajest.fn()asnextor call the original undecorated function.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 === 4means error handler,length === 3means request handler.catchAsyncpreserves the original arity (3 vs 4), so wrapping(err, req, res, next) => ...still registers correctly viaapp.use(catchAsync(myErrorHandler)). Do not strip parameters.
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 preservederrorCodes
import { errorCodes } from "@hiprax/errors";A Map<number, string> of all standard HTTP 4xx/5xx status codes and their text descriptions. 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? }). |
| ErrorFactory | ./httpErrors | The signature shared by every httpErrors.* factory: (message?, options?) => ErrorHandler. |
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 anexportsmap - Full
.d.tstype declarations sideEffects: falsefor optimal tree-shaking
Testing
npm testRuns the Jest test suite covering all modules.
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
