Recipe · Frameworks and tests

How do I type an Express handler in JavaScript?

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

The problem My Express handlers are plain JavaScript, so req.params and req.body are untyped. How do I get them checked without moving to TypeScript?

The short answer

Install @types/express, then check each named handler against the RequestHandler type it provides. In a JavaScript file, write that with @satisfies, not @type:

server.js server.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";

const app = express();

/** @satisfies {import("express").RequestHandler<{ id: string }>} */
const getUser = (req, res) => {
  res.json({ id: req.params.id, name: "Ada" });
};

app.get("/users/:id", getUser);

app.get("/orders/:orderId", (req, res) => {
  res.json({ orderId: req.params.orderId });
});

app.listen(3000);
import express, { type RequestHandler } from "express";

const app = express();

const getUser = ((req, res) => {
  res.json({ id: req.params.id, name: "Ada" });
}) satisfies RequestHandler<{ id: string }>;

app.get("/users/:id", getUser);

app.get("/orders/:orderId", (req, res) => {
  res.json({ orderId: req.params.orderId });
});

app.listen(3000);
A named handler and an inline one.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

RequestHandler<{ id: string }> says the route has one parameter, id. The handler’s req and res take their types from it, so req.params.id is a string and a misspelled parameter is an error. The inline handler for /orders/:orderId needs no annotation at all: @types/express reads the parameter names out of the route string.

Install the types

Express itself is a normal dependency; its types and the checker are development dependencies. Version 5 of @types/express describes Express 5.

npm install express
npm install --save-dev typescript @types/express @types/node

The examples on this page use the Node.js ES modules setup with strict on.

Why @satisfies and not @type

The obvious spelling, @type, fails as soon as a handler does not return anything:

server.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: server.js(7,17): error TS2355: A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. server.js(7,17): error TS2355: A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value. 7.0.2 : differs, see why TypeScript 7.0.2, run 21 October 2026: differs from 5.9.3. No errors. checked 21 October 2026
import express from "express";

const app = express();

/** @type {import("express").RequestHandler<{ id: string }>} */
const getUser = (req, res) => {error TS2355: A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.
  res.json({ id: req.params.id });
};

app.get("/users/:id", getUser);
@type on a handler that returns nothing.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

In a JavaScript file, @type on a function annotates the whole function, its return type included. RequestHandler declares its return type as unknown, so a block body with no return statement is held to “must return a value” and fails with TS2355. A type annotation on a variable in a .ts file only types the arrow function from its context, which is why the TypeScript version of the same code has no error.

@satisfies (TypeScript 5.0 and later) is the JSDoc tag that behaves like that annotation here: it checks the function against RequestHandler and gives its parameters their types, and the function keeps its own inferred return type. Two other spellings also work: separate @param tags for req and res (typed import("express").Request<{ id: string }> and import("express").Response), or keeping @type and writing return res.json(…).

TypeScript 7.0 reworked its checking of .js files to match .ts files, and TypeScript 7.0.2 does not report the @type handler above: its badge shows the difference, and the section Under TypeScript 6.0 and 7.0 explains it. @satisfies passes in every version, so it stays the spelling to use (TypeScript 7.0 for JavaScript users).

A parameter the route does not have

With the parameters typed, a name that is not in the type is caught before the request ever runs:

server.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: server.js(8,29): error TS2339: Property 'userId' does not exist on type '{ id: string; }'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. server.js(8,29): error TS2339: Property 'userId' does not exist on type '{ id: string; }'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. server.js(8,29): error TS2339: Property 'userId' does not exist on type '{ id: string; }'. checked 21 October 2026
import express from "express";

const app = express();

/** @satisfies {import("express").RequestHandler<{ id: string }>} */
const getUser = (req, res) => {
  res.json({ id: req.params.userId });error TS2339: Property 'userId' does not exist on type '{ id: string; }'.
};

app.get("/users/:id", getUser);
A route parameter that does not exist.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

Typing the request body and the error handler

req.body is any until you say what it holds. RequestHandler takes the route parameters, the response body and the request body as its first three type parameters, in that order:

users.js 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 {object} NewUser
 * @property {string} name
 * @property {string} [email]
 */

/** @typedef {{ id: number, name: string }} CreatedUser */

/** @satisfies {RequestHandler<{}, CreatedUser, NewUser>} */
const createUser = (req, res) => {
  const { name } = req.body;
  res.status(201).json({ id: Date.now(), name });
};

/** @satisfies {ErrorRequestHandler} */
const onError = (err, req, res, next) => {
  if (res.headersSent) return next(err);
  res.status(500).json({ error: "Something went wrong" });
};

const app = express();
app.use(express.json());
app.post("/users", createUser);
app.use(onError);
Typing the request body and the error handler.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}
  • @import (TypeScript 5.5 and later) brings RequestHandler and ErrorRequestHandler into scope once, instead of repeating import("express") in every tag.
  • @typedef names the shapes. {} as the first type parameter means the route has no parameters.
  • res.json() is now checked against CreatedUser, so a response with a missing or misspelled field is an error.
  • Express treats a middleware function with four parameters as an error handler. ErrorRequestHandler types all four; err is any, because anything can be thrown.

A type on req.body tells the checker what you expect, not what a client sent. Check the body at runtime before you trust it; the guide on runtime types and annotations shows how one function can do both.

Checklist

  • Install @types/express (and @types/node) as development dependencies.
  • Put @satisfies {import("express").RequestHandler<…>} on each named handler.
  • Leave inline handlers alone: the route string types their parameters.
  • Type the body with the third type parameter, and validate it at runtime.

Under TypeScript 6.0 and 7.0

Every example on this page also runs under TypeScript 6.0.3 and 7.0.2 when this site is built. One of them reports something different from 5.9.3, the version the page describes; the ✗ on its badge links to its entry below.

A function typed with @type is not checked for a missing return

TypeScript 7.0 · JSDoc and JavaScript patterns 7.0 no longer reads

The pattern: /** @type {RequestHandler} */ on an arrow function that returns nothing, where the handler type's return type is not void.

Why: 5.9 reports TS2355 ("must return a value") on the arrow function; TypeScript 7.0.2 does not. The port's list of changes does not mention it. Express ignores what a handler returns, so the code itself is fine. Source: CHANGES.md: changes between TypeScript 6 and the native port.

@type on a handler that returns nothing server.js

TypeScript 5.9.3
server.js(7,17): error TS2355: A function whose declared type is neither 'undefined', 'void', nor 'any' must return a value.
TypeScript 6.0.3same as 5.9.3

The same errors as 5.9.3.

TypeScript 7.0.2differs from 5.9.3

No errors.

What works in every version: Use @satisfies instead of @type: the handler is checked against the type and keeps its own return type, the same in every version.

The example A named handler and an inline one on this page shows it, with the same result under every version.

Questions

Do I need TypeScript installed to use @types/express?

You need the typescript package as a development dependency to run the checker, but your code stays JavaScript. @types/express is read only by tsc and your editor; nothing in it runs.

Why is req.body typed any?

Express cannot know what a client sends, so @types/express types the body as any until you pass a body type as the third type parameter of RequestHandler. That type is a promise about the data, not a check: validate the body at runtime as well.

Sources

Last reviewed 21 October 2026.