Skip to content
CodeMorph
Learn

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.

Intermediate2 min readUpdated 11 October 2026

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/node

Typing a route handler

Request takes four type parameters: route params, response body, request body and query string.

src/routes/cart.ts
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:

src/types/express.d.ts
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.

Next guideCommonJS to ES modules during a TypeScript migration

Let CodeMorph do the heavy lifting.

Download the beta, open a project and see your migration plan in under a minute.