Recipe · Frameworks and tests

How do I type Express middleware and error handlers in JavaScript?

  • Node.js (ES modules)
  • Express
  • Middleware
  • @satisfies
  • @types/express
  • Checked with TypeScript 5.9

The problem My Express app has middleware that stamps each request, a requireRole() factory and an error handler. How do I type them in JSDoc so res.locals and the error handler's arguments are checked?

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:

app.js app.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
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);
A middleware factory, a middleware that fills res.locals, and an error handler.
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):

app.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: app.js(10,14): error TS2551: Property 'requestID' does not exist on type 'Locals & Locals'. Did you mean 'requestId'? app.js(12,26): error TS2339: Property 'orderId' does not exist on type '{}'. app.js(27,31): error TS2345: Argument of type '"admin"' is not assignable to parameter of type '"customer" | "staff"'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. app.js(10,14): error TS2551: Property 'requestID' does not exist on type 'Locals & Locals'. Did you mean 'requestId'? app.js(12,26): error TS2339: Property 'orderId' does not exist on type '{}'. app.js(27,31): error TS2345: Argument of type '"admin"' is not assignable to parameter of type '"customer" | "staff"'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. app.js(10,14): error TS2551: Property 'requestID' does not exist on type 'Locals & Locals'. Did you mean 'requestId'? app.js(12,26): error TS2339: Property 'orderId' does not exist on type '{}'. app.js(27,31): error TS2345: Argument of type '"admin"' is not assignable to parameter of type '"customer" | "staff"'. checked 21 October 2026
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"'.
A typo in res.locals, a route parameter the middleware does not have, and a role outside the union.
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; keep next even when unused.
  • res.locals types are per handler. The Locals you give one middleware does not reach the next handler’s res.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 as next("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

Last reviewed 21 October 2026.