A req.body.titel typo, a controller that expects req.user on an unauthenticated route, a service called with a string instead of a number: TypeScript catches all of these before the server starts. Express has excellent type definitions, and modern tooling runs .ts files with no build step during development. After this lesson you will be able to set up an Express 5 project in TypeScript, type handlers, params, bodies and middleware, extend the Request type safely, and build for production.
npm install express
npm install --save-dev typescript tsx @types/node @types/express@5
npx tsc --init@types/express@5 matches the Express 5 API; the default @types/express may still resolve to version 4 types, which mis-describe req.query and the promise-returning router. A compact tsconfig.json for Node 20+:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}With module: NodeNext and "type": "module" in package.json, relative imports must use the .js extension even though the source file is .ts (import app from "./app.js"), because that is the path Node will see after compilation.
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}tsx executes TypeScript directly and restarts on change; tsc produces plain JavaScript in dist/ for production. Node 23.6+ can also strip types itself for files that avoid enums and other syntax needing transformation.
The Request generic takes four parameters: route params, response body, request body and query. Supply the ones you use and leave the rest as defaults:
// src/controllers/todo.controller.ts
import type { Request, Response, NextFunction } from "express";
interface TodoParams { id: string }
interface CreateTodoBody { title: string; priority?: "low" | "high" }
export async function show(req: Request<TodoParams>, res: Response) {
const todo = await todoService.getOrFail(req.params.id); // id: string
res.json({ data: todo });
}
export async function create(req: Request<{}, unknown, CreateTodoBody>, res: Response) {
const todo = await todoService.create(req.body); // body: CreateTodoBody
res.status(201).json({ data: todo });
}Query strings are typed through the fourth parameter, for example Request<{}, unknown, unknown, { page?: string }>; the values stay strings until you convert them.
Types on req.body describe what you expect, not what arrived: keep Zod validation in front of the controller and derive the type from the schema with z.infer<typeof createTodoSchema> so the two never drift apart.
Middleware uses the RequestHandler and ErrorRequestHandler aliases, which also type next:
import type { RequestHandler, ErrorRequestHandler } from "express";
export const requireAuth: RequestHandler = (req, res, next) => {
if (!req.user) {
res.status(401).json({ error: "Authentication required" });
return;
}
next();
};
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
const status = err instanceof AppError ? err.statusCode : 500;
res.status(status).json({ error: err instanceof AppError ? err.message : "Internal Server Error" });
};Returning res.status(...).json(...) from a RequestHandler is a type error because the handler is declared to return void; send the response, then return; on its own line.
Properties added by your middleware (req.user, req.validated, req.todo) do not exist on the built-in type. Add them with declaration merging in a .d.ts file inside src:
// src/types/express.d.ts
import type { AuthUser } from "../services/auth.service.js";
declare global {
namespace Express {
interface Request {
user?: AuthUser;
validated?: unknown;
}
}
}
export {};The trailing export {} makes the file a module so declare global works. Mark the properties optional; after requireAuth runs you can narrow with req.user! or a small getUser(req) helper that throws when it is missing.
@types/express without the @5 tag and getting Express 4 types: req.query becomes writable and async errors look unhandled..js in relative imports under NodeNext; the code compiles under tsx but fails when Node runs dist/.any for req.body to silence errors. Type the body through the Zod schema instead.In `Request<P, ResBody, ReqBody, ReqQuery>`, which position types `req.body`?
typescript, tsx, @types/node and @types/express@5; use module: NodeNext and .js import specifiers.tsx watch in development and tsc to dist/ for production; recent Node versions can strip types directly.Request generic and derive body types from Zod schemas.RequestHandler and ErrorRequestHandler for middleware and return void after sending.Express.Request with declaration merging for properties your middleware adds.Next lesson: Performance: Compression, Clustering and PM2 — measure throughput, compress responses, and use every CPU core with the cluster module and PM2.