30.1 Express with TypeScript: Typed Request and Response
Right, let’s get you set up with Express and TypeScript without losing your mind. The first thing you’ll realize is that Express, bless its heart, was not built with type safety in mind. Out of the box, its Request and Response objects are about as typed as a bowl of soup. Your job is to add the cutlery.
The Vanilla (and Painfully Untyped) Problem
If you just start writing Express handlers with TypeScript, you’re in for a world of any. Look at this sad, lonely handler:
import express from 'express';
const app = express();
app.get('/user/:id', (req, res) => {
// What even is `req.params.id`? TypeScript says `any`.
// Want to read a body? `req.body` is also `any`.
const userId = req.params.id; // type: any
const body = req.body; // type: any
// This is a ticking time bomb.
res.send(`User: ${userId}`);
});
app.listen(3000);
This is useless. You might as well be using JavaScript. The default Request interface has no idea about your dynamic route parameters (:id), and it certainly doesn’t know what shape the JSON body of a POST request should have. We can do so much better.
Extending the Request Interface
The key to unlocking type safety is understanding that Request is an interface. And in TypeScript, interfaces are open for extension. This is your “in.” You don’t replace the entire Request type; you add to it.
Let’s say you have a route that expects a userId parameter and a request body that conforms to a User type.
// types/express/index.d.ts
import { User } from '../../src/models/User'; // Your custom type
declare global {
namespace Express {
interface Request {
user?: User; // Example of adding a user property (common for auth middleware)
}
}
}
But for route-specific types, you’ll do this more directly in your route handler. Here’s how you properly type the request object for a specific route.
The Right Way: Generics and RequestHandler
The express package provides generic types for this exact purpose. The main event is the RequestHandler type. Its generics allow you to specify the types for:
Params(e.g.,{ id: string })ResBody(what you send back)ReqBody(what you expect to receive)Query(for query string values)
Let’s create a robust example.
// src/models/User.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface CreateUserRequest {
name: string;
email: string;
// password would be here, but let's not get into that mess
}
Now, let’s use these types in a well-typed handler.
// src/routes/users.ts
import { RequestHandler } from 'express';
import { User, CreateUserRequest } from '../models/User';
// Handler for GET /users/:userId
// Params: has a `userId`
// ResBody: we will send back a User object
// ReqBody: not used here, so we leave it as the default
// Query: not used here
export const getUser: RequestHandler<
{ userId: string }, // ParamsDictionary
User, // ResBody
never, // ReqBody
never // QueryString
> = async (req, res) => {
// Now, the magic happens:
const userId = req.params.userId; // type: string
// req.body is typed as 'never' since we don't expect one. Trying to access it is a type error.
// ... you'd fetch the user from a database here ...
const user: User = { id: parseInt(userId), name: 'Alice', email: 'alice@example.com' };
res.json(user); // TypeScript will now complain if `user` doesn't match the `User` interface.
};
// Handler for POST /users
// Params: none, so we use an empty object or `never`
// ResBody: we'll send back the created User
// ReqBody: our CreateUserRequest object
export const createUser: RequestHandler<
never,
User,
CreateUserRequest
> = async (req, res) => {
// The body is now fully typed!
const { name, email } = req.body; // types: string, string
if (!name || !email) {
// Still have to do runtime validation, because TypeScript types are a lie at runtime.
// Someone could curl you with a bad JSON body. Never trust the client.
res.status(400).send('Name and email are required');
return;
}
// ... create user in DB ...
const newUser: User = { id: 123, name, email };
res.status(201).json(newUser);
};
The Middleware Gotcha
Here’s the big “aha!” moment that trips everyone up. If you write custom middleware that adds a property to the req object (like req.user after validating a JWT), TypeScript will scream at you in your route handlers.
You extended the interface, right? So why the error? Because the default RequestHandler type doesn’t know about your custom extension. The fix is to use a custom type that incorporates your extended request.
// src/types/express/index.d.ts
import { User } from '../../models/User';
declare global {
namespace Express {
interface Request {
user?: User; // Now this property is known globally
}
}
}
// src/middleware/auth.ts
import { RequestHandler } from 'express';
// This middleware now adds a `user` property. TypeScript is cool with it.
export const authenticate: RequestHandler = (req, res, next) => {
// ... fake auth logic ...
req.user = { id: 1, name: 'Authenticated User', email: 'auth@example.com' };
next();
};
// src/routes/protected.ts
export const getProfile: RequestHandler = (req, res) => {
// Now, `req.user` is correctly typed as `User | undefined`
if (!req.user) {
res.status(401).send('Wait, how did you even get here?');
return;
}
res.json({ message: `Hello ${req.user.name}` });
};
The beauty of this is that it’s accurate. It correctly types req.user as potentially undefined because that’s exactly what it is until your middleware runs. It forces you to write the defensive code you should be writing anyway. You’re not just making TypeScript happy; you’re writing better, more resilient code. And that’s the whole point.