@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
- Yarn
- pnpm
- Bun
npm install @web-ts-toolkit/express-response-handler @web-ts-toolkit/http-errors express
yarn add @web-ts-toolkit/express-response-handler @web-ts-toolkit/http-errors express
pnpm add @web-ts-toolkit/express-response-handler @web-ts-toolkit/http-errors express
bun add @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
- Yarn
- pnpm
- Bun
npm install -D typescript @types/express @types/node
yarn add --dev typescript @types/express @types/node
pnpm add -D typescript @types/express @types/node
bun add --dev 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(...)HttpResponsecreateHandler(...)ErrorFormatsCSVResponse,Response, and 10 success classes:Accepted,AlreadyReported,Created,IMUsed,MultiStatus,NoContent,NonAuthoritativeInfo,OK,PartialContent,ResetContent
Published subpaths:
@web-ts-toolkit/express-response-handler/typesfor public handler and middleware types such asExpressResponseHandlerOptionsandHandleResponse@web-ts-toolkit/express-response-handler/responsesfor response-wrapper exports@web-ts-toolkit/express-response-handler/responses/csvforCSVResponse@web-ts-toolkit/express-response-handler/responses/successfor concrete success wrappers such asCreated,Accepted, andNoContent
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
undefinedmeans 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 = fnapiHandler.postJson = fnapiHandler.preError = fnapiHandler.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.