Building a Typed REST API with Express

Advanced
13 min

Building a Typed REST API with Express

Express is the most common way to build HTTP APIs in Node.js, and TypeScript makes its loosely typed request and response objects far safer. This lesson sets up an Express 5 project with TypeScript, types route parameters, bodies and responses, validates incoming JSON at the boundary, and writes typed error middleware. The result is a small task API you can extend into the capstone project.

Project Setup

bash
mkdir task-api && cd task-api && npm init -y npm install express npm install --save-dev typescript tsx @types/node @types/express npx tsc --init

Express 5 is the current major; pair it with @types/express 5. Use a Node.js-oriented tsconfig.json:

json
{ "compilerOptions": { "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "outDir": "dist", "rootDir": "src", "types": ["node"], "esModuleInterop": true }, "include": ["src"] }

Add "type": "module" to package.json and these scripts:

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

tsx runs TypeScript directly with restart-on-save; tsc emits JavaScript for production.

Typing Requests and Responses

Request and Response are generic. The Request type parameters are, in order, route params, response body, request body and query string; Response takes the response body type:

typescript
import type { Request, Response } from "express"; interface CreateTaskBody { title: string } interface TaskParams { id: string } // params are always strings app.post("/tasks", (req: Request<{}, Task, CreateTaskBody>, res: Response<Task>) => { const task: Task = { id: tasks.length + 1, title: req.body.title, done: false }; tasks.push(task); res.status(201).json(task); }); app.patch("/tasks/:id", (req: Request<TaskParams, Task | { error: string }, Partial<Task>>, res) => { const task = tasks.find((t) => t.id === Number(req.params.id)); if (!task) return res.status(404).json({ error: "Task not found" }); Object.assign(task, req.body); res.json(task); });

Typing res as Response<Task> makes res.json({ wrong: true }) a compile error. RequestHandler<Params, ResBody, ReqBody, Query> gives the same checks to handlers defined outside app.get.

Validating Input at the Boundary

Annotations on req.body describe what you expect; they cannot check what a client sent. Validate with a schema library such as Zod and derive the type from the schema so the two never drift:

typescript
import { z } from "zod"; const CreateTask = z.object({ title: z.string().min(1).max(120), done: z.boolean().default(false), }); type CreateTaskInput = z.infer<typeof CreateTask>; app.post("/tasks", (req, res) => { const parsed = CreateTask.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: "Invalid body", issues: parsed.error.issues }); } const input: CreateTaskInput = parsed.data; // fully typed and verified const task: Task = { id: tasks.length + 1, ...input }; tasks.push(task); res.status(201).json(task); });

safeParse returns a discriminated union on success, so the usual narrowing applies.

Middleware and Error Handling

Middleware functions are typed with RequestHandler; error handlers must declare four parameters, which is how Express tells them apart:

typescript
import type { RequestHandler, ErrorRequestHandler } from "express"; const requestLog: RequestHandler = (req, _res, next) => { console.log(`${req.method} ${req.path}`); next(); }; const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { const status = err instanceof HttpError ? err.status : 500; res.status(status).json({ error: err instanceof Error ? err.message : "Unknown error" }); }; app.use(requestLog); app.get("/tasks/:id", async (req, res) => { const task = await repo.find(Number(req.params.id)); // if this throws... if (!task) throw new HttpError(404, "Task not found"); res.json(task); }); app.use(errorHandler); // ...Express 5 forwards the rejection here automatically

Express 5 catches rejected promises from async handlers and passes them to error middleware, so the try/catch wrappers Express 4 needed are gone. The err parameter is any in the typings; treat it as unknown and narrow.

To attach data such as the authenticated user to req, augment Request in a declaration file (declare module "express-serve-static-core" { interface Request { user?: User } }).

Tips

  • Keep route handlers thin: parse, call a service function, respond. Services are plain TypeScript and easy to test.
  • Return early after res.status(...).json(...) to avoid "headers already sent".
Quick Quiz
Question 1 of 3

In `Request<P, ResBody, ReqBody, Query>`, what is the type of `req.params` values?

Key Takeaways

  • Install express with @types/express and @types/node; use tsx watch for development and tsc for builds.
  • Request<Params, ResBody, ReqBody, Query> and Response<Body> make handler contracts explicit and checked.
  • Validate request bodies with a schema library and derive the static type from the schema.
  • Error middleware takes four parameters; Express 5 forwards rejected async handlers to it automatically.
  • Augment Request via module augmentation to carry typed data such as the current user.

Next lesson: Testing TypeScript with Vitest and Jest — write typed unit tests, mocks and type-level assertions.

Building a Typed REST API with Express - TypeScript | CodeYourCraft | CodeYourCraft