This final chapter assembles the course into one small, production-shaped project: a task API with a typed domain model, a generic repository, a service layer returning Result values, validated Express routes and Vitest tests. Every piece uses a technique from an earlier lesson.
mkdir task-api && cd task-api && npm init -y
npm install express zod
npm install --save-dev typescript tsx vitest @types/node @types/express
npx tsc --initReuse the Express lesson's Node.js tsconfig.json with "noUncheckedIndexedAccess": true added, organised by layer:
src/
domain/task.ts types, Result, transitions
repo/task-repo.ts Repository interface + in-memory store
service/task-service.ts business rules
service/task-service.test.ts
http/routes.ts Express router + Zod
server.ts
The domain module (sample above) has no dependencies: Result models expected failures and TRANSITIONS encodes the workflow rule in data.
// src/repo/task-repo.ts
export interface Repository<T extends { id: string }> {
find(id: string): Promise<T | undefined>;
save(item: T): Promise<T>;
}
export class InMemoryRepository<T extends { id: string }> implements Repository<T> {
private items = new Map<string, T>();
async find(id: string) { return this.items.get(id); }
async save(item: T) { this.items.set(item.id, item); return item; }
}The constraint T extends { id: string } is all the repository needs. A database-backed class that implements Repository<Task> can replace it later without touching the layers above.
// src/service/task-service.ts
import { canTransition, type NewTask, type Result, type Task, type TaskPatch } from "../domain/task.js";
import type { Repository } from "../repo/task-repo.js";
export type TaskError = "NOT_FOUND" | "INVALID_TRANSITION";
export class TaskService {
constructor(private readonly repo: Repository<Task>) {}
create(input: NewTask): Promise<Task> {
return this.repo.save({ id: crypto.randomUUID(), title: input.title, status: input.status ?? "todo", createdAt: new Date() });
}
async update(id: string, patch: TaskPatch): Promise<Result<Task, TaskError>> {
const task = await this.repo.find(id);
if (!task) return { ok: false, error: "NOT_FOUND", message: `Task ${id} not found` };
if (patch.status && !canTransition(task.status, patch.status)) {
return { ok: false, error: "INVALID_TRANSITION", message: `${task.status} -> ${patch.status} is not allowed` };
}
return { ok: true, value: await this.repo.save({ ...task, ...patch }) };
}
}The service knows nothing about HTTP; its failures are values with a literal error code that the router maps to status codes.
// src/http/routes.ts
import { Router } from "express";
import { z } from "zod";
import type { TaskService, TaskError } from "../service/task-service.js";
const PatchSchema = z.object({ title: z.string().min(1), status: z.enum(["todo", "in-progress", "done"]) }).partial();
const STATUS: Record<TaskError, number> = { NOT_FOUND: 404, INVALID_TRANSITION: 409 };
export function taskRoutes(service: TaskService) {
const router = Router();
router.patch("/:id", async (req, res) => {
const parsed = PatchSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "VALIDATION", issues: parsed.error.issues });
const result = await service.update(req.params.id, parsed.data);
if (!result.ok) return res.status(STATUS[result.error]).json({ error: result.error, message: result.message });
res.json(result.value);
});
return router;
}STATUS is a Record<TaskError, number>, so adding a new error code to the service without a mapping here is a compile error: exhaustiveness without a switch. A POST / route follows the same shape with service.create.
TaskService depends only on the Repository<Task> interface, so its rules are tested without HTTP or a database: construct new TaskService(new InMemoryRepository<Task>()), create a task, call update(task.id, { status: "done" }) and assert expect(result).toMatchObject({ ok: false, error: "INVALID_TRANSITION" }).
Wire server.ts with express.json(), app.use("/tasks", taskRoutes(service)) and the Express lesson's error middleware, then run npx tsx watch src/server.ts and npx vitest.
From here, add GET /tasks?status=done with a Zod query schema, swap in a database-backed repository, add type-checked ESLint and a tsc --noEmit CI step, and share domain with a React client via project references.
Why does adding a new `TaskError` member break the build in `routes.ts`?
Result, and a thin HTTP layer.TRANSITIONS, STATUS) typed with Record so the compiler enforces completeness.Repository<Task>) so stores can be swapped and tests stay fast.What to learn next: deepen the server side with the Express.js and MongoDB courses, build a typed client with the Next.js course, and keep advanced types sharp in the Practice section.