Typing an Express Application
Express is written in plain JavaScript, so out of the box its req and res objects are loosely typed and easy to misuse. Adding the community-maintained type definitions turns Express into a fully typed framework: handlers get autocompletion for res.status(), route params are checked, and you can describe the exact shape of a JSON body or query string. This page shows how to install the types, annotate request handlers, parameterise Request for params/body/query, write typed middleware, and safely augment the Request interface for properties you attach yourself.
Installing the type definitions
Express does not ship its own types, so install the @types/express package alongside Express. As a build-time dependency, the types belong in devDependencies, while Express itself is a runtime dependency.
npm install express
npm install --save-dev @types/express @types/node typescript tsx
@types/express pulls in @types/express-serve-static-core (where the core Request/Response generics live) and @types/qs. With those installed, importing Express in a .ts file gives you full typing immediately.
// src/app.ts
import express, { type Request, type Response } from "express";
const app = express();
app.use(express.json());
app.get("/health", (req: Request, res: Response) => {
res.json({ status: "ok" });
});
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Import
RequestandResponseas types (import { type Request }) so the names cannot collide with the globalRequest/Responsefrom the DOM/fetch lib, which are different shapes entirely.
Typing req and res
When you annotate a handler’s parameters with Request and Response, TypeScript checks every method you call. The compiler knows res.status() returns the response (so it chains), that res.json() accepts any serialisable value, and that req.headers is a typed object.
app.get("/whoami", (req: Request, res: Response) => {
const agent = req.get("user-agent") ?? "unknown";
res.status(200).json({ ip: req.ip, agent });
});
Often you can skip the annotations entirely. Because app.get() expects a RequestHandler, the parameters are inferred — but annotating them is what unlocks the generic params/body/query typing below.
Generic Request types for params, body and query
The Request interface is generic. Its type parameters, in order, describe the route params, the response body, the request body, and the query string:
Request<Params, ResBody, ReqBody, ReqQuery>
Supplying these tells TypeScript exactly what req.params, req.body, and req.query contain.
| Position | Parameter | Types this property |
|---|---|---|
| 1st | Params | req.params |
| 2nd | ResBody | the res.json() / res.send() body |
| 3rd | ReqBody | req.body |
| 4th | ReqQuery | req.query |
Here a POST /users/:teamId reads a typed body and a typed route param. Define interfaces for each shape, then plug them into the generic:
interface TeamParams {
teamId: string;
}
interface CreateUserBody {
name: string;
email: string;
}
interface UserResponse {
id: number;
name: string;
}
app.post(
"/users/:teamId",
(req: Request<TeamParams, UserResponse, CreateUserBody>, res: Response<UserResponse>) => {
const { teamId } = req.params; // string
const { name, email } = req.body; // typed CreateUserBody
console.log(`Adding ${email} to team ${teamId}`);
res.status(201).json({ id: 1, name });
},
);
Note that req.body is only as trustworthy as your parser — the types describe the expected shape, not validated data. Pair them with a runtime validator (Zod, Valibot) before trusting the input.
Query strings are typed through the fourth parameter, but values arrive as strings (or arrays of strings), never numbers:
interface SearchQuery {
q: string;
page?: string;
}
app.get("/search", (req: Request<{}, unknown, unknown, SearchQuery>, res: Response) => {
const page = Number(req.query.page ?? "1");
res.json({ term: req.query.q, page });
});
Typing middleware
Middleware uses the RequestHandler type (or ErrorRequestHandler for error handlers). Annotating next as NextFunction keeps the signature correct and ensures you forward errors properly.
import type { Request, Response, NextFunction, RequestHandler } from "express";
const requestTimer: RequestHandler = (req, res, next) => {
const start = performance.now();
res.on("finish", () => {
const ms = (performance.now() - start).toFixed(1);
console.log(`${req.method} ${req.path} -> ${res.statusCode} (${ms}ms)`);
});
next();
};
app.use(requestTimer);
An error-handling middleware must declare all four parameters so Express recognises it as an error handler:
import type { ErrorRequestHandler } from "express";
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Internal Server Error" });
};
app.use(errorHandler);
Output:
GET /search -> 200 (2.4ms)
POST /users/42 -> 201 (5.1ms)
Augmenting the Request interface
Authentication middleware commonly attaches a user to the request. TypeScript does not know about that property, so accessing req.user errors. Rather than casting everywhere, use declaration merging to add fields to Express’s Request interface globally.
Create a types/express.d.ts file and merge into the Express namespace:
// types/express.d.ts
import "express";
declare global {
namespace Express {
interface Request {
user?: { id: number; role: "admin" | "member" };
}
}
}
Make sure the file is included by your tsconfig.json (it is, if your include covers the folder, e.g. "include": ["src/**/*.ts", "types/**/*.d.ts"]). Now req.user is known everywhere:
const authenticate: RequestHandler = (req, res, next) => {
// ...verify token, then:
req.user = { id: 7, role: "admin" };
next();
};
app.get("/admin", authenticate, (req: Request, res: Response) => {
if (req.user?.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
res.json({ message: `Welcome, user ${req.user.id}` });
});
The augmentation file must contain at least one top-level
importorexport(hereimport "express") so TypeScript treats it as a module. A plain ambient.d.tswith no imports will replace rather than merge the globalExpressnamespace.
Best Practices
- Keep Express in
dependenciesand@types/expressindevDependencies, matching its major version where possible. - Annotate handlers with
Request<Params, ResBody, ReqBody, ReqQuery>to typeparams,body, andqueryprecisely. - Treat typed
req.bodyas a claim, not a guarantee — validate at runtime with a schema library before use. - Type middleware with
RequestHandlerand error handlers withErrorRequestHandlerso signatures stay correct. - Augment the
Express.Requestinterface via declaration merging for properties you attach (likereq.user) instead of casting. - Import
Request/Responseas types to avoid clashing with the global DOM/fetchRequest/Response.