The short answer
Check middleware against RequestHandler with @satisfies, give a factory @returns {RequestHandler}, and check the error handler against ErrorRequestHandler. Type res.locals through RequestHandler’s fifth type parameter:
import express from "express";
/** @import { RequestHandler, ErrorRequestHandler } from "express" */
/** @typedef {{ requestId: string, startedAt: number }} Locals */
export class HttpError extends Error {
/**
* @param {number} status
* @param {string} message
*/
constructor(status, message) {
super(message);
this.status = status;
}
}
/** @satisfies {RequestHandler<{}, unknown, unknown, {}, Locals>} */
const stamp = (req, res, next) => {
res.locals.requestId = crypto.randomUUID();
res.locals.startedAt = Date.now();
next();
};
/**
* @param {"customer" | "staff"} role
* @returns {RequestHandler}
*/
export function requireRole(role) {
return (req, res, next) => {
if (req.get("x-role") !== role) return next(new HttpError(403, "Forbidden"));
next();
};
}
/** @satisfies {ErrorRequestHandler} */
const onError = (err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err instanceof HttpError ? err.status : 500;
res.status(status).json({ error: status === 500 ? "Something went wrong" : err.message });
};
const app = express();
app.use(stamp);
app.get("/admin/orders", requireRole("staff"), (req, res) => {
res.json({ orders: [], requestId: res.locals.requestId });
});
app.use(onError);import express, { type ErrorRequestHandler, type RequestHandler } from "express";
type Locals = { requestId: string; startedAt: number };
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
const stamp = ((req, res, next) => {
res.locals.requestId = crypto.randomUUID();
res.locals.startedAt = Date.now();
next();
}) satisfies RequestHandler<{}, unknown, unknown, {}, Locals>;
export function requireRole(role: "customer" | "staff"): RequestHandler {
return (req, res, next) => {
if (req.get("x-role") !== role) return next(new HttpError(403, "Forbidden"));
next();
};
}
const onError = ((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err instanceof HttpError ? err.status : 500;
res.status(status).json({ error: status === 500 ? "Something went wrong" : err.message });
}) satisfies ErrorRequestHandler;
const app = express();
app.use(stamp);
app.get("/admin/orders", requireRole("staff"), (req, res) => {
res.json({ orders: [], requestId: res.locals.requestId });
});
app.use(onError);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} stamp may only set the two fields Locals declares, requireRole only accepts the two roles, and onError gets all four parameters typed, with err narrowed by instanceof HttpError.
Why it works
RequestHandler<Params, ResBody, ReqBody, Query, Locals> describes (req, res, next) for one kind of route. The first four type parameters shape req and the response body; the fifth shapes res.locals, the per-request object Express gives middleware for passing data along. @satisfies types the arrow function’s parameters from it without changing the function’s own type, which is why it is the tag the Express handler recipe recommends.
A factory returns a new middleware on each call, so its @returns {RequestHandler} types the returned arrow function in the same way.
The same thing in TypeScript
The TypeScript equivalent tab uses satisfies RequestHandler<…> and declares status: number on HttpError, which TypeScript needs before the constructor can assign it. In JavaScript the assignment in the constructor declares the field.
What goes wrong
A misspelled res.locals field is caught with a suggestion (TS2551); a middleware typed for routes without parameters cannot read req.params.orderId (TS2339); and a role outside the union is refused at the call (TS2345):
import express from "express";
/** @import { RequestHandler } from "express" */
/** @typedef {{ requestId: string }} Locals */
/** @satisfies {RequestHandler<{}, unknown, unknown, {}, Locals>} */
const stamp = (req, res, next) => {
res.locals.requestID = crypto.randomUUID();error TS2551: Property 'requestID' does not exist on type 'Locals & Locals'. Did you mean 'requestId'?
console.log(req.params.orderId);error TS2339: Property 'orderId' does not exist on type '{}'.
next();
};
/**
* @param {"customer" | "staff"} role
* @returns {RequestHandler}
*/
function requireRole(role) {
return (req, res, next) => (req.get("x-role") === role ? next() : res.sendStatus(403));
}
const app = express();
app.use(stamp);
app.get("/admin", requireRole("admin"), (req, res) => res.end());error TS2345: Argument of type '"admin"' is not assignable to parameter of type '"customer" | "staff"'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} Gotchas
- An error handler needs all four parameters at runtime. Express recognizes error-handling middleware by its four declared parameters. A three-parameter function still passes
@satisfies {ErrorRequestHandler}, because fewer parameters are always allowed, so tsc cannot catch this; keepnexteven when unused. res.localstypes are per handler. TheLocalsyou give one middleware does not reach the next handler’sres.locals; use the same type argument on each, or augment Express’s types once for the whole app.next("route")skips the rest of a route’s handlers. It is typed as accepting any value, so a typo such asnext("rout")is passed on as an error instead.
Questions
Does Express 5 pass rejected promises to the error handler?
Yes, per the Express 5 documentation: an async handler or middleware that rejects (or throws) is forwarded to the error-handling middleware, so next(error) is no longer needed for that case.
Why is err typed any in the error handler?
Anything can be thrown or passed to next(), so @types/express types it as any. Narrow it with instanceof, as the example does with HttpError, before reading its properties.
Sources
- Writing middleware · Express docs
- Error handling · Express docs
- @types/express · npm
Last reviewed 21 October 2026.