Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hal-response

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 failures
  • HalSuccess → for successes
  • AsyncHandler → wraps async controllers so thrown errors reach your handler
npm install hal-response

Requires Node.js >= 22. Peer: Express 5.

Table of contents


The response envelope

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" }
}

service is optional. If you construct HalSuccess without 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.


Quick start

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);
});

Errors

Throwing errors

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" }
);

The error middleware

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 LAST
JavaScript
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.

Built-in error classes

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

Custom error classes

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");

Success responses

Configure once, reuse

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.

Success methods

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 },
});

AsyncHandler

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.

Using transactions inside a controller

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 AsyncHandler route it to the HalError handler.
  • HalErrors work naturally — throw ConflictError, 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.

API reference

HalError (abstract)

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

HalSuccess

Constructed with { service?: string } (the argument and service are both optional). Methods: ok, created, accepted, noContent, partialContent, and the general serialize(data, statusCode?, opts?).

AsyncHandler(fn)

Wraps an async Express controller and forwards errors to next.

Exported types

SerializedErrorResponse, SerializedSuccessResponse<T>, HalLinks, HalSuccessConfig, SerializeOptions, AsyncController.


License

MIT © nitin thakur

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages