Express is the most widely used Node.js web framework. Compared with the raw node:http server from the previous lessons, it gives you routing with parameters (/students/:id), body parsing (express.json()), helpers such as res.status(201).json(...), and middleware: functions that run in order for every request. Express 5 also passes errors from async handlers to your error middleware automatically.
A REST API maps HTTP methods onto resources: GET /students lists, GET /students/:id reads one, POST creates (201 plus a Location header), PATCH changes some fields, DELETE removes (204, no body). req.body arrives as untyped JSON, so check it with a type guard (body is NewStudent) before trusting it. Throw an HttpError with a status for expected problems and let one error handler (the middleware with four parameters) turn every error into a JSON response.
Export a createApp() function instead of starting the server in the same file. server.ts calls listen, and tests start the same app on port 0 (any free port) and call it with fetch, so they exercise real HTTP: routing, status codes, headers and JSON.
npm install express
npm install --save-dev @types/express
npx tsx watch src/server.ts # restarts on every saveimport express, { type NextFunction, type Request, type Response } from "express";
export interface Student {
id: number;
name: string;
form: number;
}
interface NewStudent {
name: string;
form: number;
}
// A type guard: after it returns true, TypeScript knows `body` is a NewStudent.
function isNewStudent(body: unknown): body is NewStudent {
if (typeof body !== "object" || body === null) return false;
const { name, form } = body as Record<string, unknown>;
return typeof name === "string" && name.trim().length >= 2 && Number.isInteger(form) && (form as number) >= 1 && (form as number) <= 6;
}
export class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export function createApp(students: Student[] = []) {
const app = express();
app.use(express.json()); // parses JSON bodies into req.body
let nextId = Math.max(0, ...students.map((s) => s.id)) + 1;
function findStudent(id: string): Student {
const student = students.find((s) => s.id === Number(id));
if (!student) throw new HttpError(404, `Student ${id} not found`);
return student;
}
app.get("/students", (_req, res) => {
res.json(students);
});
app.get("/students/:id", (req, res) => {
res.json(findStudent(req.params.id));
});
app.post("/students", (req, res) => {
if (!isNewStudent(req.body)) throw new HttpError(400, "name (2+ letters) and form (1-6) are required");
const student: Student = { id: nextId++, name: req.body.name.trim(), form: req.body.form };
students.push(student);
res.status(201).location(`/students/${student.id}`).json(student);
});
app.patch("/students/:id", (req, res) => {
const student = findStudent(req.params.id);
const changes = { ...student, ...req.body, id: student.id };
if (!isNewStudent(changes)) throw new HttpError(400, "invalid name or form");
Object.assign(student, { name: changes.name.trim(), form: changes.form });
res.json(student);
});
app.delete("/students/:id", (req, res) => {
const student = findStudent(req.params.id);
students.splice(students.indexOf(student), 1);
res.status(204).end();
});
// Unknown routes, then one error handler for everything (Express 5 also catches async errors).
app.use((_req, res) => {
res.status(404).json({ error: "Not found" });
});
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof HttpError) return res.status(err.status).json({ error: err.message });
if (err instanceof SyntaxError) return res.status(400).json({ error: "Body must be valid JSON" });
console.error(err);
res.status(500).json({ error: "Something went wrong" });
});
return app;
}import { createApp } from "./app";
const port = Number(process.env.PORT ?? 3000);
const app = createApp([{ id: 1, name: "Amina Hassan", form: 4 }]);
app.listen(port, () => console.log(`API on http://localhost:${port}`));import { after, before, describe, it } from "node:test";
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { createApp } from "../src/app";
let server: Server;
let base: string;
before(async () => {
server = createApp([{ id: 1, name: "Amina Hassan", form: 4 }]).listen(0); // port 0 = any free port
await new Promise((resolve) => server.once("listening", resolve));
base = `http://localhost:${(server.address() as AddressInfo).port}`;
});
after(() => server.close());
const post = (path: string, body: string) =>
fetch(base + path, { method: "POST", headers: { "Content-Type": "application/json" }, body });
describe("students API", () => {
it("creates a student and returns 201 with a Location header", async () => {
const res = await post("/students", JSON.stringify({ name: "Juma Said", form: 3 }));
assert.equal(res.status, 201);
assert.equal(res.headers.get("location"), "/students/2");
assert.deepEqual(await res.json(), { id: 2, name: "Juma Said", form: 3 });
});
it("rejects invalid input and broken JSON with 400", async () => {
assert.equal((await post("/students", JSON.stringify({ name: "J", form: 9 }))).status, 400);
assert.equal((await post("/students", "{not json")).status, 400);
});
it("updates, deletes and then 404s", async () => {
const patched = await fetch(`${base}/students/1`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ form: 5 }),
});
assert.equal((await patched.json()).form, 5);
assert.equal((await fetch(`${base}/students/1`, { method: "DELETE" })).status, 204);
assert.equal((await fetch(`${base}/students/1`)).status, 404);
});
});node --import tsx --test test/*.test.ts # runs the HTTP testsKey points
- Routes plus middleware:
express.json()first, then routes, then a 404 handler, then the error handler. - Validate
req.bodywith a type guard; use 201/204/400/404 status codes correctly. - Export
createApp()so tests can run the real app on port 0 and call it with fetch.
Exercise
Make GET /students accept ?form=4&search=am&limit=10&offset=0. Parse and check every query value (400 for limit=500 or form=abc), filter by form and name, and return { total, items }. Keep the logic in pure functions and test them with node:test.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Query-string values are always strings (or arrays of strings when a key repeats), so parseListQuery converts each one, checks its range and throws the lesson's HttpError(400), which the error middleware already turns into JSON. Filtering and paging are a pure function, so most tests need no server at all. total counts every match before paging, so a client can show "page 2 of 5".
import { HttpError, type Student } from "./app";
export interface ListQuery {
form?: number;
search?: string;
limit: number;
offset: number;
}
// req.query values are strings (or arrays of strings), so convert and check each one.
export function parseListQuery(query: Record<string, unknown>): ListQuery {
const int = (key: string, fallback: number, min: number, max: number): number => {
const raw = query[key];
if (raw === undefined) return fallback;
const n = Number(raw);
if (typeof raw !== "string" || !Number.isInteger(n) || n < min || n > max) {
throw new HttpError(400, `${key} must be a whole number from ${min} to ${max}`);
}
return n;
};
return {
form: query.form === undefined ? undefined : int("form", 0, 1, 6),
search: typeof query.search === "string" && query.search.trim() !== "" ? query.search.trim().toLowerCase() : undefined,
limit: int("limit", 20, 1, 100),
offset: int("offset", 0, 0, Number.MAX_SAFE_INTEGER),
};
}
export function listStudents(students: Student[], q: ListQuery): { total: number; items: Student[] } {
const matches = students.filter(
(s) => (q.form === undefined || s.form === q.form) && (q.search === undefined || s.name.toLowerCase().includes(q.search)),
);
return { total: matches.length, items: matches.slice(q.offset, q.offset + q.limit) };
}
// In app.ts, replace the GET /students route with:
// app.get("/students", (req, res) => {
// res.json(listStudents(students, parseListQuery(req.query as Record<string, unknown>)));
// });import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { listStudents, parseListQuery } from "../src/list";
const students = [
{ id: 1, name: "Amina Hassan", form: 4 },
{ id: 2, name: "Juma Said", form: 3 },
{ id: 3, name: "Ali Mohamed", form: 4 },
{ id: 4, name: "Neema Kimaro", form: 4 },
];
describe("parseListQuery", () => {
it("uses defaults", () => {
assert.deepEqual(parseListQuery({}), { form: undefined, search: undefined, limit: 20, offset: 0 });
});
it("rejects bad numbers with a 400 HttpError", () => {
assert.throws(() => parseListQuery({ limit: "500" }), { status: 400, message: /limit must be/ });
assert.throws(() => parseListQuery({ form: "abc" }), { status: 400 });
assert.throws(() => parseListQuery({ offset: ["1", "2"] }), { status: 400 }); // ?offset=1&offset=2
});
});
describe("listStudents", () => {
it("filters by form and search, then pages", () => {
const q = parseListQuery({ form: "4", search: "am", limit: "1", offset: "1" });
assert.deepEqual(listStudents(students, q), { total: 2, items: [{ id: 3, name: "Ali Mohamed", form: 4 }] });
});
});