Typing an Express API
Type route handlers, request bodies, middleware and errors in an Express app, and validate input at runtime so the types stay true.
Express is one of the most common things people migrate to TypeScript, and one of the easiest to type badly. Here's how to type it well.
Install the types
npm install --save-dev typescript @types/express @types/nodeTyping a route handler
Request takes four type parameters: route params, response body, request body and query string.
import { Router, type Request, type Response } from "express";
import { addToCart, type Cart, type CartItem } from "../lib/cart";
const router = Router();
router.post(
"/carts/:userId/items",
async (req: Request<{ userId: string }, Cart, CartItem>, res: Response<Cart>) => {
const cart = await addToCart(req.params.userId, req.body);
res.status(201).json(cart);
},
);
export default router;Now req.params.userId is a string, req.body is a CartItem, and res.json only accepts a Cart.
Types aren't validation
Request<…, CartItem> is a promise you make to the compiler, not a check. Anyone can send any JSON. Validate at the boundary and derive the type from the validator, so they can never drift apart:
import { z } from "zod";
const CartItemSchema = z.object({
sku: z.string().min(1),
qty: z.number().int().positive(),
price: z.number().nonnegative(),
});
type CartItem = z.infer<typeof CartItemSchema>;
router.post("/carts/:userId/items", async (req, res) => {
const parsed = CartItemSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const cart = await addToCart(req.params.userId, parsed.data);
res.status(201).json(cart);
});Adding properties to the request
Authentication middleware usually attaches a user. Declare it once with declaration merging:
import type { User } from "../models/user";
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
export {};Every handler now sees req.user as User | undefined, which forces you to handle the logged-out case.
Middleware and errors
import type { RequestHandler, ErrorRequestHandler } from "express";
export const requireAuth: RequestHandler = (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
next();
};
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const status = typeof err?.status === "number" ? err.status : 500;
res.status(status).json({ error: status === 500 ? "Internal error" : String(err.message) });
};Register the error handler last. Express recognizes it by its four arguments.
Async handlers
Express 5 passes rejected promises from async handlers to your error handler automatically. On Express 4, wrap async handlers so errors don't become unhandled rejections:
const wrap =
(fn: RequestHandler): RequestHandler =>
(req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);Migrating an existing Express app
Convert in dependency order: utilities and the database layer first, then models, then middleware and routes, and app.ts and server.ts last. Each layer is typed against the one below it. That's exactly the plan CodeMorph builds from your imports, and Run & Verify confirms the app still typechecks, passes its tests and builds.