Capstone Project: A Fully Typed Task API

Advanced
16 min

Capstone Project: A Fully Typed Task API

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.

Project Setup and Layout

bash
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 --init

Reuse 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.

Generic Repository

typescript
// 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.

Service Layer With Result

typescript
// 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.

HTTP Layer With Validation

typescript
// 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.

Tests and Wiring

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.

Quick Quiz
Question 1 of 3

Why does adding a new `TaskError` member break the build in `routes.ts`?

Key Takeaways

  • Layer the code: a dependency-free domain, a generic repository, a service returning Result, and a thin HTTP layer.
  • Encode rules in data (TRANSITIONS, STATUS) typed with Record so the compiler enforces completeness.
  • Validate input with Zod at the boundary and pass only typed data inward.
  • Depend on interfaces (Repository<Task>) so stores can be swapped and tests stay fast.
  • Every technique here came from an earlier chapter; reuse this structure in real projects.

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.

Capstone Project: A Fully Typed Task API - TypeScript | CodeYourCraft | CodeYourCraft