Skip to content
Node.js nd typescript 5 min read

Typing an Express Application

Express is written in plain JavaScript, so out of the box its req and res objects are loosely typed and easy to misuse. Adding the community-maintained type definitions turns Express into a fully typed framework: handlers get autocompletion for res.status(), route params are checked, and you can describe the exact shape of a JSON body or query string. This page shows how to install the types, annotate request handlers, parameterise Request for params/body/query, write typed middleware, and safely augment the Request interface for properties you attach yourself.

Installing the type definitions

Express does not ship its own types, so install the @types/express package alongside Express. As a build-time dependency, the types belong in devDependencies, while Express itself is a runtime dependency.

npm install express
npm install --save-dev @types/express @types/node typescript tsx

@types/express pulls in @types/express-serve-static-core (where the core Request/Response generics live) and @types/qs. With those installed, importing Express in a .ts file gives you full typing immediately.

// src/app.ts
import express, { type Request, type Response } from "express";

const app = express();
app.use(express.json());

app.get("/health", (req: Request, res: Response) => {
  res.json({ status: "ok" });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

Import Request and Response as types (import { type Request }) so the names cannot collide with the global Request/Response from the DOM/fetch lib, which are different shapes entirely.

Typing req and res

When you annotate a handler’s parameters with Request and Response, TypeScript checks every method you call. The compiler knows res.status() returns the response (so it chains), that res.json() accepts any serialisable value, and that req.headers is a typed object.

app.get("/whoami", (req: Request, res: Response) => {
  const agent = req.get("user-agent") ?? "unknown";
  res.status(200).json({ ip: req.ip, agent });
});

Often you can skip the annotations entirely. Because app.get() expects a RequestHandler, the parameters are inferred — but annotating them is what unlocks the generic params/body/query typing below.

Generic Request types for params, body and query

The Request interface is generic. Its type parameters, in order, describe the route params, the response body, the request body, and the query string:

Request<Params, ResBody, ReqBody, ReqQuery>

Supplying these tells TypeScript exactly what req.params, req.body, and req.query contain.

PositionParameterTypes this property
1stParamsreq.params
2ndResBodythe res.json() / res.send() body
3rdReqBodyreq.body
4thReqQueryreq.query

Here a POST /users/:teamId reads a typed body and a typed route param. Define interfaces for each shape, then plug them into the generic:

interface TeamParams {
  teamId: string;
}

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

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

app.post(
  "/users/:teamId",
  (req: Request<TeamParams, UserResponse, CreateUserBody>, res: Response<UserResponse>) => {
    const { teamId } = req.params;      // string
    const { name, email } = req.body;   // typed CreateUserBody
    console.log(`Adding ${email} to team ${teamId}`);
    res.status(201).json({ id: 1, name });
  },
);

Note that req.body is only as trustworthy as your parser — the types describe the expected shape, not validated data. Pair them with a runtime validator (Zod, Valibot) before trusting the input.

Query strings are typed through the fourth parameter, but values arrive as strings (or arrays of strings), never numbers:

interface SearchQuery {
  q: string;
  page?: string;
}

app.get("/search", (req: Request<{}, unknown, unknown, SearchQuery>, res: Response) => {
  const page = Number(req.query.page ?? "1");
  res.json({ term: req.query.q, page });
});

Typing middleware

Middleware uses the RequestHandler type (or ErrorRequestHandler for error handlers). Annotating next as NextFunction keeps the signature correct and ensures you forward errors properly.

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

const requestTimer: RequestHandler = (req, res, next) => {
  const start = performance.now();
  res.on("finish", () => {
    const ms = (performance.now() - start).toFixed(1);
    console.log(`${req.method} ${req.path} -> ${res.statusCode} (${ms}ms)`);
  });
  next();
};

app.use(requestTimer);

An error-handling middleware must declare all four parameters so Express recognises it as an error handler:

import type { ErrorRequestHandler } from "express";

const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Internal Server Error" });
};

app.use(errorHandler);

Output:

GET /search -> 200 (2.4ms)
POST /users/42 -> 201 (5.1ms)

Augmenting the Request interface

Authentication middleware commonly attaches a user to the request. TypeScript does not know about that property, so accessing req.user errors. Rather than casting everywhere, use declaration merging to add fields to Express’s Request interface globally.

Create a types/express.d.ts file and merge into the Express namespace:

// types/express.d.ts
import "express";

declare global {
  namespace Express {
    interface Request {
      user?: { id: number; role: "admin" | "member" };
    }
  }
}

Make sure the file is included by your tsconfig.json (it is, if your include covers the folder, e.g. "include": ["src/**/*.ts", "types/**/*.d.ts"]). Now req.user is known everywhere:

const authenticate: RequestHandler = (req, res, next) => {
  // ...verify token, then:
  req.user = { id: 7, role: "admin" };
  next();
};

app.get("/admin", authenticate, (req: Request, res: Response) => {
  if (req.user?.role !== "admin") {
    return res.status(403).json({ error: "Forbidden" });
  }
  res.json({ message: `Welcome, user ${req.user.id}` });
});

The augmentation file must contain at least one top-level import or export (here import "express") so TypeScript treats it as a module. A plain ambient .d.ts with no imports will replace rather than merge the global Express namespace.

Best Practices

  • Keep Express in dependencies and @types/express in devDependencies, matching its major version where possible.
  • Annotate handlers with Request<Params, ResBody, ReqBody, ReqQuery> to type params, body, and query precisely.
  • Treat typed req.body as a claim, not a guarantee — validate at runtime with a schema library before use.
  • Type middleware with RequestHandler and error handlers with ErrorRequestHandler so signatures stay correct.
  • Augment the Express.Request interface via declaration merging for properties you attach (like req.user) instead of casting.
  • Import Request/Response as types to avoid clashing with the global DOM/fetch Request/Response.
Last updated June 14, 2026
Was this helpful?