Skip to content

CAPSTONE PROJECT · LESSON 22 OF 24

Capstone: a tested task domain

Build reusable task behavior before connecting it to a screen.

  • domain model
  • immutability
  • validation
  • tests

Your runnable checkpoint

Extract into a new practice folder. Each download includes complete source, package.json, tsconfig.json and an offline lesson. Use Node.js 22 or 24. Each checkpoint is independent.

Install with npm install. Run npm run check, then npm run build. Terminal lessons use npm start; browser lessons use npm run serve and http://127.0.0.1:4173.

1. Understand the concepts

A domain module owns business rules independently of the browser. Our Task has an id, a title and a completion flag. createTask trims the title and rejects invalid values; callers decide how to show those errors.

Pure transformations make behavior easy to test. toggleTask returns a new array and creates a new object only for the matching task. removeTask filters by id. An unknown id leaves the contents unchanged rather than silently modifying another task.

parseTasks validates data loaded from storage. It checks every field, reuses title validation, rejects duplicate ids and constructs fresh objects. Local storage is an external boundary too: an older app version or user can change it.

Run npm run check, npm run build and npm start. The executable test program checks creation, trimming, immutability, toggling, deletion and invalid stored data. The next lesson reuses this exact domain module in a browser app.

2. Build and run

These are complete files. Preserve the paths shown below, including the src directory. The package and compiler configuration are included at the end.

src/main.ts

src/main.ts
import { createTask, toggleTask, removeTask, parseTasks } from "./tasks.js";
function assert(condition: boolean, message: string): void { if (!condition) throw new Error(message); }
const original = [createTask("1", "  Learn TypeScript  ")];
assert(original[0]?.title === "Learn TypeScript", "Trim title");
const changed = toggleTask(original, "1");
assert(changed[0]?.done === true, "Toggle");
assert(original[0]?.done === false, "Do not mutate input");
assert(removeTask(changed, "1").length === 0, "Remove");
assert(parseTasks(JSON.parse(JSON.stringify(changed)))[0]?.done === true, "Round trip");
let rejected = false;
try { parseTasks([{ id: "1", title: "Task", done: "yes" }]); }
catch { rejected = true; }
assert(rejected, "Reject malformed storage");
console.log("6 task checks passed");

src/tasks.ts

src/tasks.ts
export type Task = { readonly id: string; title: string; done: boolean };
export function createTask(id: string, title: string): Task {
  const clean = title.trim();
  if (!id.trim()) throw new Error("Id is required");
  if (clean.length < 1 || clean.length > 120) throw new Error("Use 1–120 characters");
  return { id, title: clean, done: false };
}
export function toggleTask(tasks: readonly Task[], id: string): Task[] {
  return tasks.map(task => task.id === id ? { ...task, done: !task.done } : task);
}
export function removeTask(tasks: readonly Task[], id: string): Task[] {
  return tasks.filter(task => task.id !== id);
}
export function parseTasks(value: unknown): Task[] {
  if (!Array.isArray(value)) throw new Error("Expected a task array");
  const ids = new Set<string>();
  return value.map((item: unknown) => {
    if (typeof item !== "object" || item === null
        || !("id" in item) || typeof item.id !== "string"
        || !("title" in item) || typeof item.title !== "string"
        || !("done" in item) || typeof item.done !== "boolean") throw new Error("Invalid task");
    const task = createTask(item.id, item.title);
    if (ids.has(task.id)) throw new Error("Duplicate task id");
    ids.add(task.id);
    return { ...task, done: item.done };
  });
}

package.json

package.json
{
  "name": "edupoly-typescript-capstone-task-domain",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "check": "tsc --noEmit",
    "build": "tsc",
    "start": "node dist/main.js"
  },
  "devDependencies": {
    "typescript": "5.9.3"
  }
}

tsconfig.json

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": [
      "ES2022",
      "DOM"
    ],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noEmitOnError": true,
    "verbatimModuleSyntax": true,
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,
    "types": [],
    "moduleDetection": "force"
  },
  "include": [
    "src/**/*"
  ]
}

3. Trace the code and check the result

  1. The tests import the same functions that the browser will use.
  2. Checking original after toggling catches accidental mutation.
  3. The JSON round trip exercises the boundary parser with real serialized data.

Expected result

6 task checks passed

4. Practice independently

Test duplicate ids and blank titles.

Show the explanation

Call parseTasks with two valid objects sharing an id and assert that it throws. Separately call createTask("2", " ") and assert that it throws.

When something goes wrong

If tsc is missing, run npm install in the folder containing package.json. If the output looks old, rebuild before running. Read the first compiler diagnostic and check the indicated field or argument. For module errors, preserve the .js extensions in imports. Browser examples must run through the included local server, not directly as TypeScript files.

Read the related official TypeScript documentation ↗