Typed, consistent HTTP responses for Node.js / Express APIs. Every reply — success or failure — is serialized into one predictable JSON envelope, so your clients can rely on a single contract.
HalError+ ready-made error classes → for failuresHalSuccess→ for successesAsyncHandler→ wraps async controllers so thrown errors reach your handler
npm install hal-responseRequires Node.js >= 22. Peer: Express 5.
Both sides share the same skeleton, so the client can branch on success:
if (body.success) {
// use body.data
} else {
// show body.error.message
}Success
{
"success": true,
"statusCode": 200,
"service": "userService",
"message": "User fetched",
"data": { "id": 1, "name": "Nitin" },
"meta": { "requestId": "req_1", "timestamp": "2026-07-18T10:00:01Z" }
}
serviceis optional. If you constructHalSuccesswithout one (new HalSuccess()), the field is omitted from the response entirely.
Error
{
"success": false,
"statusCode": 400,
"error": {
"code": "BAD_REQUEST",
"message": "Invalid request payload",
"details": [{ "field": "email", "issue": "Email format is invalid" }]
},
"meta": { "requestId": "req_1", "timestamp": "2026-07-18T10:00:01Z" }
}Optional fields (message, _links, details, requestId, extra meta) appear only
when you provide them. meta.timestamp is always generated.
import express from "express";
import {
HalSuccess,
AsyncHandler,
HalError,
NotFoundError,
} from "hal-response";
const app = express();
// 1. configure the success builder once per controller/service
const response = new HalSuccess({ service: "userService" });
// 2. wrap async controllers so thrown errors reach the middleware
app.get(
"/users/:id",
AsyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError("User not found");
res.status(200).json(response.ok(user));
})
);
// 3. one error middleware, registered LAST
app.use((err, req, res, next) => {
if (err instanceof HalError) {
res.status(err.statusCode).json(err.serialize());
return;
}
next(err);
});Every error class takes the same constructor:
new BadRequestError(message, details?, meta?)| Argument | Type | Purpose |
|---|---|---|
message |
string |
Human-readable message |
details |
Record<string, any>[] |
Optional field-level info (validation etc) |
meta |
Record<string, any> |
Optional extras merged into response meta |
import { UnprocessableEntityError } from "hal-response";
throw new UnprocessableEntityError(
"Validation failed",
[
{ field: "email", issue: "Email format is invalid" },
{ field: "age", issue: "Must be 18 or older" },
],
{ attemptId: "abc123" }
);Register one handler, after all routes. It catches anything that is a
HalError and serializes it; everything else is passed on to Express.
TypeScript
import { HalError } from "hal-response";
import type { Request, Response, NextFunction } from "express";
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof HalError) {
res.status(err.statusCode).json(
// optional: HAL links + a request id
err.serialize({ self: { href: req.originalUrl } }, req.headers["x-request-id"] as string)
);
return;
}
next(err); // not one of ours — let Express handle it
}
// app.use(errorHandler) // register LASTJavaScript
const { HalError } = require("hal-response");
function errorHandler(err, req, res, next) {
if (err instanceof HalError) {
res.status(err.statusCode).json(
err.serialize({ self: { href: req.originalUrl } }, req.headers["x-request-id"])
);
return;
}
next(err);
}
module.exports = { errorHandler };serialize(links?, requestId?) — both arguments optional.
Import any of these and throw it. Each fixes its own statusCode + code.
| Class | Status | error.code |
|---|---|---|
BadRequestError |
400 | BAD_REQUEST |
UnauthorizedError |
401 | UNAUTHORIZED |
ForbiddenError |
403 | FORBIDDEN |
NotFoundError |
404 | NOT_FOUND |
ConflictError |
409 | CONFLICT |
PayloadTooLargeError |
413 | PAYLOAD_TOO_LARGE |
UnprocessableEntityError |
422 | UNPROCESSABLE_ENTITY |
TooManyRequestsError |
429 | RATE_LIMITED |
InternalServerError |
500 | INTERNAL_SERVER_ERROR |
NotImplementedError |
501 | NOT_IMPLEMENTED |
BadGatewayError |
502 | BAD_GATEWAY |
ServiceUnavailableError |
503 | SERVICE_UNAVAILABLE |
GatewayTimeoutError |
504 | GATEWAY_TIMEOUT |
Extend HalError and set statusCode + code:
import { HalError } from "hal-response";
import { StatusCodes } from "http-status-codes";
export class PaymentRequiredError extends HalError {
statusCode = StatusCodes.PAYMENT_REQUIRED; // 402
code = "PAYMENT_REQUIRED";
}
throw new PaymentRequiredError("Subscription expired");Unlike errors, a success is built in the controller, not thrown. Configure
HalSuccess once per controller with the service name, then reuse it:
import { HalSuccess } from "hal-response";
// top of userController.ts
const response = new HalSuccess({ service: "userService" });
export const getUser = AsyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
res.status(200).json(response.ok(user));
});
export const createUser = AsyncHandler(async (req, res) => {
const user = await db.createUser(req.body);
res.status(201).json(response.created(user, { requestId: req.headers["x-request-id"] }));
});The configured service is emitted at the top level of every response. It is
optional — call new HalSuccess() with no argument (or {}) to omit it.
| Method | Status | When to use |
|---|---|---|
ok(data, opts?) |
200 | GET, or a successful update (PUT/PATCH) |
created(data, opts?) |
201 | POST that created a new resource |
accepted(data, opts?) |
202 | Async / background job accepted |
noContent(opts?) |
204 | Success with no body (e.g. DELETE) |
partialContent(data, opts?) |
206 | Range requests (streaming, downloads) |
Anything else: serialize(data, statusCode, opts?).
opts is { message?, links?, requestId?, meta? } — all optional:
response.ok(users, {
message: "Users fetched",
links: { self: { href: "/users" }, next: { href: "/users?page=2" } },
requestId: req.headers["x-request-id"],
meta: { total: 128 },
});Express does not automatically route a rejected promise from an async
controller to your error middleware. AsyncHandler wraps the controller so any
thrown error (or rejection) is forwarded via next(err) — which is what lets a
throw new NotFoundError(...) reach the HalError handler.
import { AsyncHandler } from "hal-response";
router.get(
"/users/:id",
AsyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError("User not found"); // caught -> next(err)
res.json(response.ok(user));
})
);Rule: wrap every async controller with AsyncHandler.
AsyncHandler forwards a thrown error to your middleware, but it does not
know about your database transaction — so you must roll back before the error
escapes. The pattern: try the work, catch to roll back and re-throw
(so AsyncHandler still routes it to the HalError handler), and finally to
release the session.
Mongoose (MongoDB session)
import mongoose from "mongoose";
import { AsyncHandler, HalSuccess, ConflictError } from "hal-response";
const response = new HalSuccess({ service: "walletService" });
export const transfer = AsyncHandler(async (req, res) => {
const { from, to, amount } = req.body;
const session = await mongoose.startSession();
session.startTransaction();
try {
const sender = await Wallet.findById(from).session(session);
if (sender.balance < amount) {
// a HalError thrown here is caught below, rolled back, then re-thrown
throw new ConflictError("Insufficient balance");
}
await Wallet.updateOne({ _id: from }, { $inc: { balance: -amount } }, { session });
await Wallet.updateOne({ _id: to }, { $inc: { balance: amount } }, { session });
await session.commitTransaction(); // success -> persist
res.status(200).json(response.ok({ from, to, amount }));
} catch (err) {
await session.abortTransaction(); // failure -> undo everything
throw err; // re-throw -> AsyncHandler -> middleware
} finally {
session.endSession(); // always release the session
}
});Prisma (SQL, interactive transaction)
import { AsyncHandler, HalSuccess, ConflictError } from "hal-response";
const response = new HalSuccess({ service: "walletService" });
export const transfer = AsyncHandler(async (req, res) => {
const { from, to, amount } = req.body;
// Prisma auto-rolls back if the callback throws — no manual abort needed.
const result = await prisma.$transaction(async (tx) => {
const sender = await tx.wallet.findUnique({ where: { id: from } });
if (sender.balance < amount) {
throw new ConflictError("Insufficient balance"); // rolls back the tx
}
await tx.wallet.update({ where: { id: from }, data: { balance: { decrement: amount } } });
await tx.wallet.update({ where: { id: to }, data: { balance: { increment: amount } } });
return { from, to, amount };
});
res.status(200).json(response.ok(result));
});Key points:
- Re-throw after rollback. Swallowing the error would leave the request
hanging; re-throwing lets
AsyncHandlerroute it to theHalErrorhandler. HalErrors work naturally — throwConflictError,BadRequestError, etc. inside the transaction and the client still gets the correct status.- With Prisma/TypeORM interactive transactions the rollback is automatic when
the callback throws; with Mongoose you abort manually in
catch.
| Member | Description |
|---|---|
statusCode |
HTTP status (defined by the subclass) |
code |
Machine-readable code (defined by the subclass) |
message |
Human-readable message |
details? |
Optional array of extra info |
meta? |
Optional extras merged into response meta |
isOperational |
Always true — marks an expected, handled error |
serialize(links?, requestId?) |
Builds the error JSON body |
Constructed with { service?: string } (the argument and service are both
optional). Methods: ok, created, accepted,
noContent, partialContent, and the general serialize(data, statusCode?, opts?).
Wraps an async Express controller and forwards errors to next.
SerializedErrorResponse, SerializedSuccessResponse<T>, HalLinks,
HalSuccessConfig, SerializeOptions, AsyncController.
MIT © nitin thakur