Typing Request & Response
By default, Express hands you a Request whose params, body, and query are loosely typed and a Response that accepts any payload. That permissiveness is convenient but throws away the main benefit of TypeScript: catching mismatched data before it reaches production. The Request and Response types from @types/express are generic, so you can pin down the exact shape of every route’s inputs and outputs and get autocompletion plus compile-time errors instead of runtime surprises.
The Request generic parameters
The Request interface accepts four type parameters, in this order:
Request<Params, ResBody, ReqBody, Query>
| Position | Parameter | Controls |
|---|---|---|
| 1 | Params | req.params — route parameters like :id |
| 2 | ResBody | the response body type (rarely set on Request) |
| 3 | ReqBody | req.body — the parsed request payload |
| 4 | Query | req.query — the parsed query string |
The order is easy to trip over because ResBody sits in the middle. A common mistake is putting the body type in position two; remember that params, then response, then request body, then query. You only need to supply parameters up to the last one you care about — the rest default to permissive types.
import express, { Request, Response } from 'express';
const app = express();
app.use(express.json());
interface UserParams {
id: string;
}
interface CreateUserBody {
name: string;
email: string;
}
interface UserQuery {
include?: 'orders' | 'profile';
}
Tip: Route params are always strings, even for numeric IDs like
/users/42. Typeidasstringand convert withNumber(req.params.id)inside the handler.
Typed route handlers
Pass your interfaces to Request at the point you declare the handler. TypeScript then narrows req.params, req.body, and req.query so accessing an unknown field or assigning the wrong type fails to compile.
app.post(
'/users/:id',
(req: Request<UserParams, unknown, CreateUserBody, UserQuery>, res: Response) => {
const { id } = req.params; // string
const { name, email } = req.body; // CreateUserBody — fully typed
const include = req.query.include; // 'orders' | 'profile' | undefined
console.log(`Updating ${id} (${name}) include=${include}`);
res.sendStatus(204);
},
);
If a caller tries req.body.role, the compiler rejects it because CreateUserBody has no role field. That single guarantee eliminates a whole category of typos.
Typing the response payload
The Response interface is also generic: Response<ResBody>. Setting it constrains res.json() and res.send() to accept only that shape, so your handler can’t accidentally return the wrong object.
interface User {
id: string;
name: string;
email: string;
}
app.get(
'/users/:id',
async (req: Request<UserParams>, res: Response<User>) => {
const user = await db.findUser(req.params.id);
res.json(user); // ✅ must match User
},
);
Now res.json({ id, name }) (missing email) is a compile error. For endpoints that return either a success body or an error envelope, use a union as the ResBody type:
interface ApiError {
error: string;
}
app.get(
'/users/:id',
async (req: Request<UserParams>, res: Response<User | ApiError>) => {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
},
);
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"User not found"}
Reusing types with a handler alias
Repeating four generics on every route is noisy. Extract a RequestHandler-style alias so each endpoint reads cleanly and the types stay in one place.
import { RequestHandler } from 'express';
// Params, ResBody, ReqBody, Query
type CreateUser = RequestHandler<UserParams, User | ApiError, CreateUserBody>;
const createUser: CreateUser = async (req, res) => {
const { name, email } = req.body; // CreateUserBody
const user = await db.createUser({ name, email });
res.status(201).json(user); // User
};
app.post('/users/:id', createUser);
Because RequestHandler carries every generic, req and res are fully typed without any inline annotations on the function parameters — TypeScript infers them from the alias.
Warning: Typed generics describe what you expect, not what actually arrived.
express.json()will happily parse a body that doesn’t matchCreateUserBody. Always validate untrusted input at runtime (Zod, Joi, express-validator) and treat the static types as a contract for trusted code paths.
Express 4 vs 5
The generic signatures of Request and Response are identical in @types/express 4 and 5. The practical difference is async handling: in Express 5 a rejected promise from a typed async handler is forwarded to your error middleware automatically, so you can drop most try/catch blocks. The typing you write for the request and response objects is unchanged across both versions.
Best Practices
- Memorize the generic order:
Request<Params, ResBody, ReqBody, Query>— the response body sits in the middle. - Type route params as
stringand convert numbers explicitly; Express never coerces them. - Set
Response<T>sores.json()is checked against your real payload shape, notany. - Model success-or-error endpoints with a union
Response<Data | ApiError>type. - Extract reusable
RequestHandler<...>aliases instead of repeating four generics on every route. - Pair static types with runtime validation — generics do not guarantee the incoming data matches at runtime.