Express ships with TypeScript definitions, but the defaults are deliberately loose. req.body is typed as any. req.params is Record<string, string>. This is necessary for a general framework, but it means TypeScript cannot help you inside route handlers without some extra work.

The default problem

app.post("/users", (req, res) => {
  const { name, email } = req.body; // name and email are both 'any'
  // TypeScript cannot catch typos or wrong field names
});

This compiles but provides no safety. Here’s how to fix it.

Typing req.body

Express’s Request type accepts generics for body, params, query, and response:

import { Request, Response } from "express";

interface CreateUserBody {
  name: string;
  email: string;
  password: string;
}

app.post(
  "/users",
  (req: Request<{}, {}, CreateUserBody>, res: Response) => {
    const { name, email, password } = req.body;
    // name, email, password are all typed correctly
  }
);

The Request generic is Request<Params, ResBody, ReqBody, Query>. To type only the body, pass empty objects for the others.

Typing req.params

interface UserParams {
  id: string; // URL params are always strings
}

app.get(
  "/users/:id",
  (req: Request<UserParams>, res: Response) => {
    const { id } = req.params; // id is string, not any
  }
);

Note that URL parameters are always strings even if you use them as numbers. Parse them explicitly:

const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
  return res.status(400).json({ error: "Invalid user ID" });
}

Typing req.query

interface UserListQuery {
  page?: string;
  limit?: string;
  search?: string;
}

app.get(
  "/users",
  (req: Request<{}, {}, {}, UserListQuery>, res: Response) => {
    const page = parseInt(req.query.page ?? "1", 10);
    const limit = parseInt(req.query.limit ?? "20", 10);
  }
);

Query parameters are also always strings (or arrays of strings). Type them as string or string | undefined, not as number.

Typing res.json

The second generic on Request is the response body type, which flows to res.json:

interface UserResponse {
  id: number;
  name: string;
  email: string;
}

app.get(
  "/users/:id",
  async (req: Request<{ id: string }>, res: Response<UserResponse>) => {
    const user = await getUser(req.params.id);
    res.json(user); // TypeScript checks that user matches UserResponse
  }
);

Custom middleware: extending Request

The most common use case is authentication middleware that adds the current user to req. Extend the Request type with module augmentation:

// types/express.d.ts
import { User } from "./models/User";

declare global {
  namespace Express {
    interface Request {
      user?: User;
    }
  }
}

Now every req.user in your codebase is typed as User | undefined.

In your auth middleware:

import { Request, Response, NextFunction } from "express";

async function authenticate(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) {
    return res.status(401).json({ error: "No token" });
  }
  try {
    req.user = await verifyToken(token); // req.user is now typed
    next();
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
}

In protected routes:

app.get("/profile", authenticate, (req, res) => {
  if (!req.user) return res.status(401).json({ error: "Unauthorized" });
  res.json({ name: req.user.name }); // TypeScript knows req.user is User here
});

Typed route handlers as functions

When handlers get complex, extract them as typed functions:

import { RequestHandler } from "express";

interface CreateUserParams {}
interface CreateUserResponse { id: number; name: string; }
interface CreateUserBody { name: string; email: string; password: string; }

const createUser: RequestHandler<
  CreateUserParams,
  CreateUserResponse,
  CreateUserBody
> = async (req, res) => {
  // req.body is fully typed
  // res.json expects CreateUserResponse
  const user = await userService.create(req.body);
  res.status(201).json({ id: user.id, name: user.name });
};

app.post("/users", createUser);

RequestHandler is the type for Express middleware and route handlers.

A complete typed route

import { Router, Request, Response } from "express";
import { z } from "zod";

const router = Router();

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
});

type CreatePostBody = z.infer<typeof CreatePostSchema>;

router.post(
  "/",
  authenticate,
  async (req: Request<{}, {}, CreatePostBody>, res: Response) => {
    const parsed = CreatePostSchema.safeParse(req.body);
    if (!parsed.success) {
      return res.status(400).json({ errors: parsed.error.flatten() });
    }
    const post = await postService.create({
      ...parsed.data,
      authorId: req.user!.id,
    });
    res.status(201).json(post);
  }
);

The combination of Zod validation and typed generics gives you full type safety from the HTTP boundary to the service layer.