Skip to main content

@web-ts-toolkit/express-response-handler

FastAPI-style return-value responses for Express.

Instead of calling res.json(...) in every route, return a value. This package turns that return value into a 200 OK JSON response, while still letting you return explicit response wrappers or throw errors when needed.

Installation​

Requires Node.js >=22 and Express ^5 (peer dependency, installed separately).

npm install @web-ts-toolkit/express-response-handler @web-ts-toolkit/http-errors express

The quickstart below imports express and @web-ts-toolkit/http-errors directly, so both must be direct dependencies. @web-ts-toolkit/http-errors is also a transitive dependency of this package, but transitive packages are not importable from an isolated consumer (pnpm), so listing it directly is required when route code throws typed errors.

For TypeScript consumers:

npm install -D typescript @types/express @types/node

Quick Start​

import express from 'express';

import apiHandler from '@web-ts-toolkit/express-response-handler';
import { NotFoundError } from '@web-ts-toolkit/http-errors';

const { handleResponse, HttpResponse } = apiHandler;

const app = express();

async function getUser(id: string) {
return id === 'missing' ? null : { id, name: 'Ada' };
}

async function createJob() {
return { id: 'job_1' };
}

app.get(
'/health',
handleResponse(() => {
return { ok: true };
}),
);

app.get(
'/users/:id',
handleResponse(async (req) => {
const user = await getUser(req.params.id);

if (!user) {
throw new NotFoundError('user not found');
}

return user;
}),
);

app.post(
'/jobs',
handleResponse(async () => {
const job = await createJob();
return HttpResponse.created(job);
}),
);

What It Exposes​

Root entrypoint:

  • default handler instance
  • handleResponse(...)
  • HttpResponse
  • createHandler(...)
  • ErrorFormats
  • CSVResponse, Response, and 10 success classes: Accepted, AlreadyReported, Created, IMUsed, MultiStatus, NoContent, NonAuthoritativeInfo, OK, PartialContent, ResetContent

Published subpaths:

  • @web-ts-toolkit/express-response-handler/types for public handler and middleware types such as ExpressResponseHandlerOptions and HandleResponse
  • @web-ts-toolkit/express-response-handler/responses for response-wrapper exports
  • @web-ts-toolkit/express-response-handler/responses/csv for CSVResponse
  • @web-ts-toolkit/express-response-handler/responses/success for concrete success wrappers such as Created, Accepted, and NoContent

Example subpath import:

import { handleResponse } from '@web-ts-toolkit/express-response-handler';
import { Created, NoContent } from '@web-ts-toolkit/express-response-handler/responses/success';

async function createUser() {
return { id: 'user_1' };
}

app.post(
'/users',
handleResponse(async () => new Created(await createUser())),
);
app.delete(
'/users/:id',
handleResponse(async () => new NoContent()),
);

Import styles​

The package supports both a default export and named exports:

import apiHandler from '@web-ts-toolkit/express-response-handler';

const { handleResponse, HttpResponse } = apiHandler;
import { handleResponse, HttpResponse } from '@web-ts-toolkit/express-response-handler';

Use one style consistently within a module so route code stays easy to scan.

How It Works​

handleResponse(...) wraps one or more Express handlers.

When a handler runs:

  • a plain returned value becomes res.json(value)
  • a returned HttpResponse.*(...) wrapper controls the status code
  • a returned HttpResponse.csv(...) streams CSV
  • a returned undefined means the handler is managing the response directly
  • a thrown error becomes an error response
  • a returned promise is awaited automatically

Supported forms:

  • handleResponse(fn)
  • handleResponse(fn1, fn2)
  • handleResponse([fn1, fn2])

Examples​

In the focused snippets below, helpers such as createSession(...), getProject(...), getUserReportRows(...), and requireAuth are application-specific placeholders.

Return JSON with 200 OK​

app.get(
'/profile',
handleResponse(async (req) => {
return {
id: req.user.id,
email: req.user.email,
};
}),
);

Return a custom success status​

app.post(
'/sessions',
handleResponse(async (req) => {
const session = await createSession(req.body);
return HttpResponse.created(session);
}),
);

Throw HTTP errors​

import { BadRequestError, NotFoundError } from '@web-ts-toolkit/http-errors';

app.get(
'/projects/:id',
handleResponse(async (req) => {
if (!req.params.id) {
throw new BadRequestError('project id is required');
}

const project = await getProject(req.params.id);

if (!project) {
throw new NotFoundError('project not found');
}

return project;
}),
);

Return CSV​

app.get(
'/reports/users.csv',
handleResponse(async () => {
const rows = await getUserReportRows();

return HttpResponse.csv(rows, {
filename: 'users.csv',
});
}),
);

CSV download filenames are emitted as standards-compliant attachment headers with an ASCII fallback and filename* for Unicode names. Filenames containing control characters such as CR, LF, or NUL are rejected before CSV headers are written.

CSV sources can be arrays, synchronous iterables, or async iterables. Arrays keep automatic header inference from the first row. Lazy iterable sources are consumed once during response streaming and must pass an explicit headers option because the handler will not peek and buffer a row just to infer headers. If the client disconnects or CSV formatting fails, the active iterator's return() method is called so generators can release database cursors, files, or other resources.

Direct CSVResponse.streamCsv(res) keeps its optional error-owner callback (no breaking change). A pre-output failure with an owner invokes it exactly once and the owner terminates the destination; the handler owner renders one redacted JSON error through the bounded fallback lifecycle. A throwing owner is contained and the destination is destroyed with the original failure. Without an owner the destination is destroyed with the normalized failure, observable via error + close. Failures after output starts always destroy the destination.

CSVResponse writes cell values exactly as supplied. It does not automatically neutralize spreadsheet formulas such as values beginning with =, +, -, or @ because some exports intentionally include formulas. If user-controlled cells may be opened in spreadsheet software, neutralize them with the processor option:

const safeCell = (value: unknown) => {
if (typeof value === 'string' && /^[=+\-@]/.test(value)) {
return `'${value}`;
}

return value;
};

const safeRow = (row: Record<string, unknown>) => {
return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, safeCell(value)]));
};

return HttpResponse.csv(rows, {
filename: 'users.csv',
processor: safeRow,
});

Use more than one Express handler​

app.get(
'/me',
handleResponse(requireAuth, async (req) => {
return req.user;
}),
);

If you call next() with no arguments, Express middleware flow continues normally.

Do not use next(value) for successful responses. Return the value instead.

Hooks​

Hooks let you observe response flow without repeating code in every route. They are observational side effects only: a hook may return void or Promise<void>, but returned values never replace or transform the response payload.

Available setters:

  • apiHandler.preJson = fn
  • apiHandler.postJson = fn
  • apiHandler.preError = fn
  • apiHandler.postError = fn

Example:

apiHandler.preJson = async function (data) {
console.log('about to send json response', data);
};

apiHandler.preError = async function (err) {
console.error('request failed', err);
};

preJson runs before a non-undefined success value is serialized. This includes plain JSON values, HttpResponse wrappers, and CSV responses. It is skipped when the handler returns undefined or has already taken manual response ownership (for example a synchronous res.* write that committed headers). Manual sync/callback responses get no success hooks and no unsolicited 500.

postJson runs after the HTTP response emits finish for a successful response. It does not run on client close, on manual undefined responses, on fallback JSON errors, or on any path that never successfully finishes a response. A failed CSV attempt never runs postJson on its fallback JSON error's finish.

preError runs before an error response is serialized, including success-path fallback errors (circular/BigInt serialization, rejected preJson, CSV-before-output failures). It always observes the original failure value. postError runs after the HTTP response emits finish for an error response, including fallback errors, and it receives the same failure that was sent (the original, or the preError failure when preError itself fails).

If a pre-hook throws or rejects before headers are sent, the failure goes through one bounded error lifecycle: one preError, one redacted error body, and one finish-timed postError, without re-entering a failing preError/provider and without running postJson. When headers are already committed (partial write), the owned failure is delegated to Express error middleware with next(err) and no second body. If a post-hook throws or rejects, the response has already completed, so the failure is passed to Express with next(err) for logging/observability and no second response is sent.

The default export is a mutable process-wide singleton. Assigning apiHandler.preJson, apiHandler.postJson, apiHandler.preError, apiHandler.postError, or apiHandler.errorMessageProvider affects every route using that singleton after assignment. Use createHandler() for isolated hook and error-provider state.

Custom Error Messages​

Unexpected non-HTTP errors default to status 500 with the generic message Internal Server Error. Raw thrown messages are not sent to clients, which prevents database, filesystem, assertion, or upstream details from leaking in production responses.

The original thrown value is still passed to preError and postError, and to Express error middleware if response serialization fails. Use those server-side paths for logging.

Only finite integer 4xx and 5xx status codes are serialized as HTTP errors. Invalid status values from thrown objects, typed wrappers, or custom providers are rejected before response headers are written.

Breaking change: older versions returned generic thrown Error messages as 422 responses. Use typed errors from @web-ts-toolkit/http-errors for intentional client-facing 4xx payloads.

You can customize generic error payloads, but provider-derived status values must still be valid HTTP error statuses:

apiHandler.errorMessageProvider = function (err) {
console.error('request failed', err);

return {
message: 'request failed',
};
};

Structured Error Format​

The default error payload is intentionally small:

{ "message": "project not found" }

If you want an AIP-193-inspired error envelope, create a handler instance with errorFormat: 'aip193':

import apiHandler from '@web-ts-toolkit/express-response-handler';
import { ErrorFormats } from '@web-ts-toolkit/express-response-handler';

const structuredHandler = apiHandler.createHandler({
errorFormat: ErrorFormats.aip193,
errorDomain: 'api.example.com',
});

That mode returns errors in this shape:

{
"error": {
"code": 404,
"status": "NOT_FOUND",
"message": "project not found",
"details": [
{
"type": "error_info",
"reason": "NOT_FOUND",
"domain": "api.example.com"
}
]
}
}

You can enrich HTTP errors with machine-readable fields:

import { BadRequestError } from '@web-ts-toolkit/http-errors';

app.get(
'/projects/:id',
structuredHandler.handleResponse(async () => {
throw new BadRequestError('invalid project id', {
reason: 'INVALID_PROJECT_ID',
metadata: { field: 'id' },
details: [
{
type: 'help',
links: [
{
description: 'Project ID format guide',
url: 'https://api.example.com/docs/errors/invalid-project-id',
},
],
},
],
});
}),
);

If you want RFC 9457 problem details instead, create a handler instance with errorFormat: 'rfc9457':

import apiHandler from '@web-ts-toolkit/express-response-handler';
import { ErrorFormats } from '@web-ts-toolkit/express-response-handler';

const problemHandler = apiHandler.createHandler({
errorFormat: ErrorFormats.rfc9457,
errorDomain: 'api.example.com',
});

That mode returns application/problem+json payloads in this shape:

{
"type": "https://api.example.com/problems/invalid-project-id",
"title": "Invalid project id",
"status": 400,
"detail": "invalid project id",
"instance": "/problems/invalid-project-id/123",
"errors": [
{
"detail": "must be a valid project id",
"pointer": "#/id"
}
]
}

You can enrich HTTP errors with problem detail fields:

import { BadRequestError } from '@web-ts-toolkit/http-errors';

app.get(
'/projects/:id',
problemHandler.handleResponse(async () => {
throw new BadRequestError('invalid project id', {
type: 'https://api.example.com/problems/invalid-project-id',
title: 'Invalid project id',
instance: '/problems/invalid-project-id/123',
errors: [
{
detail: 'must be a valid project id',
pointer: '#/id',
},
],
});
}),
);

Isolated Instances​

The default export is a ready-to-use singleton. If you want separate hook configuration per router or module, create an isolated instance:

import apiHandler from '@web-ts-toolkit/express-response-handler';

const adminHandler = apiHandler.createHandler();
const publicHandler = apiHandler.createHandler();

adminHandler.preError = async function (err) {
console.error('admin route failed', err);
};

When To Use It​

This package is a good fit when you want:

  • Express routes that return values instead of calling res.json(...)
  • a small abstraction rather than a full framework
  • consistent JSON, error, and CSV response behavior

It is less useful if you want fully explicit low-level Express response control in every route.