Skip to content

Lesson 10 of 1025 min

Final project: a typed task tracker

Put the course together in one small module - types, pure functions, results for validation, async storage behind an interface, and a few checks that prove it works.

In this lesson

  • Design types for a small domain
  • Implement pure operations and validation with results
  • Persist state through an async storage interface
  • Check behaviour with simple assertions

What you will build#

A task tracker module that a web page, a command-line tool or a test could all use. It has no UI on purpose: the interesting decisions are in the data and the rules.

  1. Types for tasks and their states
  2. Pure functions to add, complete and list tasks
  3. Validation that returns results instead of throwing
  4. Saving and loading through a storage interface
  5. A few assertions that run the whole thing

Step 1: the types#

types.ts
export type TaskId = string;export type Priority = "low" | "normal" | "high";export interface Task {  readonly id: TaskId;  title: string;  priority: Priority;  done: boolean;  readonly createdAt: string;}export type Result<T> = { ok: true; value: T } | { ok: false; error: string };

createdAt is an ISO date string rather than a Date, so tasks survive being saved as JSON unchanged.

Step 2: the whole module#

Everything below is one file. Read it top to bottom; the margin explains the decisions.

tasks.ts
type Priority = "low" | "normal" | "high";interface Task {  readonly id: string;  title: string;  priority: Priority;  done: boolean;  readonly createdAt: string;}type Result<T> = { ok: true; value: T } | { ok: false; error: string };interface TaskStore {  load(): Promise<Task[]>;  save(tasks: Task[]): Promise<void>;}function validateTitle(input: string): Result<string> {  const title = input.trim();  if (title.length === 0) return { ok: false, error: "A task needs a title" };1  if (title.length > 120) return { ok: false, error: "Keep titles under 120 characters" };  return { ok: true, value: title };}function addTask(  tasks: Task[],  input: { title: string; priority?: Priority },  now: Date,2  id: string,): Result<Task[]> {  const title = validateTitle(input.title);  if (!title.ok) return title;  const task: Task = { id, title: title.value, priority: input.priority ?? "normal", done: false, createdAt: now.toISOString() };  return { ok: true, value: [...tasks, task] };}function completeTask(tasks: Task[], id: string): Result<Task[]> {  if (!tasks.some((task) => task.id === id)) return { ok: false, error: `No task with id ${id}` };  return { ok: true, value: tasks.map((task) => (task.id === id ? { ...task, done: true } : task)) };}const rank: Record<Priority, number> = { high: 0, normal: 1, low: 2 };3function openTasks(tasks: Task[]): Task[] {  return tasks.filter((task) => !task.done).sort((a, b) => rank[a.priority] - rank[b.priority]);}class MemoryTaskStore implements TaskStore {  private tasks: Task[] = [];  async load() {    return structuredClone(this.tasks);4  }  async save(tasks: Task[]) {    this.tasks = structuredClone(tasks);  }}async function run(store: TaskStore): Promise<string[]> {  const saved = await store.load();  const first = addTask(saved, { title: "Write README" }, new Date("2026-11-01T09:00:00Z"), "t1");  if (!first.ok) throw new Error(first.error);  const second = addTask(first.value, { title: "Fix login bug", priority: "high" }, new Date("2026-11-01T10:00:00Z"), "t2");  if (!second.ok) throw new Error(second.error);  const done = completeTask(second.value, "t1");  if (!done.ok) throw new Error(done.error);  await store.save(done.value);  return openTasks(await store.load()).map((task) => task.title);}console.log(await run(new MemoryTaskStore())); // ["Fix login bug"]

Validation returns a Result: an empty title is an expected mistake, not an exception.

now and id are parameters, so the function stays pure and tests can control them. The UI passes new Date() and crypto.randomUUID().

A Record<Priority, number> must list every priority. Add "urgent" to the union and TypeScript points here.

structuredClone copies on the way in and out, so callers cannot change stored tasks by accident, just like a real database.

Step 3: check it#

Small assertions catch regressions without a test framework. Node.js ships node:assert; in a project you would move these into Vitest or Node's test runner.

tasks.check.ts
import assert from "node:assert/strict";const empty = addTask([], { title: "   " }, new Date(), "x");assert.equal(empty.ok, false);const added = addTask([], { title: "  Buy beans " }, new Date("2026-11-01T00:00:00Z"), "a");assert.ok(added.ok);assert.equal(added.value[0]?.title, "Buy beans");assert.equal(added.value[0]?.priority, "normal");assert.equal(completeTask([], "missing").ok, false);console.log("All checks passed");

Run it

Put the module and the checks in one .ts file (drop the import/export lines that split them) and run it with a recent Node.js: node tasks.ts. Older versions can use npx tsx tasks.ts.

Extend it#

ExerciseRename a task

Add renameTask(tasks, id, title): Result<Task[]> that reuses validateTitle and fails for unknown ids.

Show solution
rename.ts
function renameTask(tasks: Task[], id: string, input: string): Result<Task[]> {  const title = validateTitle(input);  if (!title.ok) return title;  if (!tasks.some((task) => task.id === id)) return { ok: false, error: `No task with id ${id}` };  return { ok: true, value: tasks.map((task) => (task.id === id ? { ...task, title: title.value } : task)) };}

ExercisePersist in the browser

Write a LocalStorageTaskStore that implements TaskStore with localStorage. What should load do when the stored JSON is missing or malformed?

Show solution

load should return an empty list when nothing is stored, and also when parsing fails or the data does not pass a validity check (use a type guard from the error-handling lesson). Losing a corrupt list is better than crashing the app; logging the problem helps you notice it.

local-store.ts
class LocalStorageTaskStore implements TaskStore {  constructor(private readonly key = "tasks") {}  async load(): Promise<Task[]> {    try {      const data: unknown = JSON.parse(localStorage.getItem(this.key) ?? "[]");      return Array.isArray(data) ? (data as Task[]) : [];    } catch {      return [];    }  }  async save(tasks: Task[]): Promise<void> {    localStorage.setItem(this.key, JSON.stringify(tasks));  }}

What you practised#

Types that model the domain, pure functions with injected time and ids, results for expected failures, an interface in front of storage, async code with await, and checks that prove the behaviour. That is most of what a well-built feature needs, at any size.