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.
mkdir task-api && cd task-api && npm init -y
npm install express
npm install --save-dev typescript tsx @types/node @types/express
npx tsc --initExpress 5 is the current major; pair it with @types/express 5. Use a Node.js-oriented tsconfig.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:
{
"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.
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:
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.
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:
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 functions are typed with RequestHandler; error handlers must declare four parameters, which is how Express tells them apart:
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 automaticallyExpress 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 } }).
res.status(...).json(...) to avoid "headers already sent".In `Request<P, ResBody, ReqBody, Query>`, what is the type of `req.params` values?
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.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.