IntermediateTypeScript · Lesson 8 of 9

REST APIs with Express

Build a typed CRUD API with Express 5: routes, params, JSON bodies, type guards, status codes, error middleware and HTTP tests.

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.

TerminalShell
npm install express
npm install --save-dev @types/express
npx tsx watch src/server.ts      # restarts on every save
src/app.tsTypeScript
import 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;
}
src/server.tsTypeScript
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}`));
test/app.test.tsTypeScript
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);
  });
});
TerminalShell
node --import tsx --test test/*.test.ts     # runs the HTTP tests

Key points

  • Routes plus middleware: express.json() first, then routes, then a 404 handler, then the error handler.
  • Validate req.body with 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".

src/list.tsTypeScript
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>)));
//   });
test/list.test.tsTypeScript
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 }] });
  });
});

Check your understanding

  1. Which status code should a successful POST /students return?

  2. How does Express recognise error-handling middleware?

  3. Why does createApp() return the app instead of calling listen itself?

  4. What type does req.query.limit have before you convert it?

Ask AI