Express with TypeScript

Advanced
14 min

Express with TypeScript

A req.body.titel typo, a controller that expects req.user on an unauthenticated route, a service called with a string instead of a number: TypeScript catches all of these before the server starts. Express has excellent type definitions, and modern tooling runs .ts files with no build step during development. After this lesson you will be able to set up an Express 5 project in TypeScript, type handlers, params, bodies and middleware, extend the Request type safely, and build for production.

Project setup

bash
npm install express npm install --save-dev typescript tsx @types/node @types/express@5 npx tsc --init

@types/express@5 matches the Express 5 API; the default @types/express may still resolve to version 4 types, which mis-describe req.query and the promise-returning router. A compact tsconfig.json for Node 20+:

json
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "rootDir": "src", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] }

With module: NodeNext and "type": "module" in package.json, relative imports must use the .js extension even though the source file is .ts (import app from "./app.js"), because that is the path Node will see after compilation.

json
{ "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js" } }

tsx executes TypeScript directly and restarts on change; tsc produces plain JavaScript in dist/ for production. Node 23.6+ can also strip types itself for files that avoid enums and other syntax needing transformation.

Typing handlers

The Request generic takes four parameters: route params, response body, request body and query. Supply the ones you use and leave the rest as defaults:

typescript
// src/controllers/todo.controller.ts import type { Request, Response, NextFunction } from "express"; interface TodoParams { id: string } interface CreateTodoBody { title: string; priority?: "low" | "high" } export async function show(req: Request<TodoParams>, res: Response) { const todo = await todoService.getOrFail(req.params.id); // id: string res.json({ data: todo }); } export async function create(req: Request<{}, unknown, CreateTodoBody>, res: Response) { const todo = await todoService.create(req.body); // body: CreateTodoBody res.status(201).json({ data: todo }); }

Query strings are typed through the fourth parameter, for example Request<{}, unknown, unknown, { page?: string }>; the values stay strings until you convert them.

Types on req.body describe what you expect, not what arrived: keep Zod validation in front of the controller and derive the type from the schema with z.infer<typeof createTodoSchema> so the two never drift apart.

Middleware uses the RequestHandler and ErrorRequestHandler aliases, which also type next:

typescript
import type { RequestHandler, ErrorRequestHandler } from "express"; export const requireAuth: RequestHandler = (req, res, next) => { if (!req.user) { res.status(401).json({ error: "Authentication required" }); return; } next(); }; export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { const status = err instanceof AppError ? err.statusCode : 500; res.status(status).json({ error: err instanceof AppError ? err.message : "Internal Server Error" }); };

Returning res.status(...).json(...) from a RequestHandler is a type error because the handler is declared to return void; send the response, then return; on its own line.

Extending the Request type

Properties added by your middleware (req.user, req.validated, req.todo) do not exist on the built-in type. Add them with declaration merging in a .d.ts file inside src:

typescript
// src/types/express.d.ts import type { AuthUser } from "../services/auth.service.js"; declare global { namespace Express { interface Request { user?: AuthUser; validated?: unknown; } } } export {};

The trailing export {} makes the file a module so declare global works. Mark the properties optional; after requireAuth runs you can narrow with req.user! or a small getUser(req) helper that throws when it is missing.

Common mistakes

  • Installing @types/express without the @5 tag and getting Express 4 types: req.query becomes writable and async errors look unhandled.
  • Omitting .js in relative imports under NodeNext; the code compiles under tsx but fails when Node runs dist/.
  • Using any for req.body to silence errors. Type the body through the Zod schema instead.
Quick Quiz
Question 1 of 3

In `Request<P, ResBody, ReqBody, ReqQuery>`, which position types `req.body`?

Key Takeaways

  • Install typescript, tsx, @types/node and @types/express@5; use module: NodeNext and .js import specifiers.
  • Run tsx watch in development and tsc to dist/ for production; recent Node versions can strip types directly.
  • Type params, body and query through the Request generic and derive body types from Zod schemas.
  • Use RequestHandler and ErrorRequestHandler for middleware and return void after sending.
  • Extend Express.Request with declaration merging for properties your middleware adds.

Next lesson: Performance: Compression, Clustering and PM2 — measure throughput, compress responses, and use every CPU core with the cluster module and PM2.

Express with TypeScript - Express.js | CodeYourCraft | CodeYourCraft