Capstone Project
Put everything together by building a type-safe task management system. This project uses generics, discriminated unions, utility types, and advanced patterns from throughout the course.
Data Models
Define the core types with strict type safety.
// Branded IDs prevent mixing different ID types
type Brand<T, B extends string> = T & { readonly __brand: B };
type TaskId = Brand<string, "TaskId">;
type UserId = Brand<string, "UserId">;
let idCounter = 0;
function createTaskId(): TaskId {
return `task_${++idCounter}` as TaskId;
}
// Task priority and status as union types
type Priority = "low" | "medium" | "high" | "urgent";
type Status = "todo" | "in_progress" | "review" | "done";
interface Task {
id: TaskId;
title: string;
description: string;
priority: Priority;
status: Status;
assignee: UserId | null;
tags: string[];
createdAt: number;
updatedAt: number;
}
type CreateTaskInput = Omit<Task, "id" | "createdAt" | "updatedAt" | "status">;
type UpdateTaskInput = Partial<Pick<Task, "title" | "description" | "priority" | "assignee" | "tags">>;
// Create a task
function createTask(input: CreateTaskInput): Task {
const now = Date.now();
return {
...input,
id: createTaskId(),
status: "todo",
createdAt: now,
updatedAt: now,
};
}
const task = createTask({
title: "Implement auth",
description: "Add OAuth2 login flow",
priority: "high",
assignee: "usr_1" as UserId,
tags: ["backend", "security"],
});
console.log(`Created: ${task.title} [${task.priority}] (${task.id})`);
console.log(`Tags: ${task.tags.join(", ")}`);
Task Store
Build a generic store with filtering and sorting.
type Priority = "low" | "medium" | "high" | "urgent";
type Status = "todo" | "in_progress" | "review" | "done";
interface Task {
id: string;
title: string;
priority: Priority;
status: Status;
assignee: string | null;
tags: string[];
createdAt: number;
}
// Generic store with type-safe queries
class TaskStore {
private tasks: Map<string, Task> = new Map();
add(task: Task): void {
this.tasks.set(task.id, task);
}
get(id: string): Task | undefined {
return this.tasks.get(id);
}
all(): Task[] {
return Array.from(this.tasks.values());
}
filter(predicate: (task: Task) => boolean): Task[] {
return this.all().filter(predicate);
}
// Type-safe field queries
byStatus(status: Status): Task[] {
return this.filter(t => t.status === status);
}
byPriority(priority: Priority): Task[] {
return this.filter(t => t.priority === priority);
}
byAssignee(assignee: string): Task[] {
return this.filter(t => t.assignee === assignee);
}
byTag(tag: string): Task[] {
return this.filter(t => t.tags.includes(tag));
}
// Statistics
stats(): Record<Status, number> {
const counts: Record<Status, number> = { todo: 0, in_progress: 0, review: 0, done: 0 };
this.all().forEach(t => counts[t.status]++);
return counts;
}
}
// Populate store
const store = new TaskStore();
const now = Date.now();
const tasks: Task[] = [
{ id: "1", title: "Setup CI/CD", priority: "high", status: "done", assignee: "alice", tags: ["devops"], createdAt: now },
{ id: "2", title: "Auth system", priority: "urgent", status: "in_progress", assignee: "alice", tags: ["backend", "security"], createdAt: now },
{ id: "3", title: "Dashboard UI", priority: "medium", status: "todo", assignee: "bob", tags: ["frontend"], createdAt: now },
{ id: "4", title: "API docs", priority: "low", status: "todo", assignee: null, tags: ["docs"], createdAt: now },
{ id: "5", title: "Write tests", priority: "high", status: "review", assignee: "bob", tags: ["testing"], createdAt: now },
];
tasks.forEach(t => store.add(t));
console.log("All tasks:", store.all().map(t => t.title).join(", "));
console.log("Urgent:", store.byPriority("urgent").map(t => t.title).join(", "));
console.log("Alice's:", store.byAssignee("alice").map(t => t.title).join(", "));
console.log("Backend:", store.byTag("backend").map(t => t.title).join(", "));
console.log("Stats:", JSON.stringify(store.stats()));
State Machine
Model task transitions with type-safe state machines.
type Status = "todo" | "in_progress" | "review" | "done";
// Define valid transitions
const validTransitions: Record<Status, Status[]> = {
todo: ["in_progress"],
in_progress: ["review", "todo"],
review: ["done", "in_progress"],
done: []
};
type TransitionResult =
| { success: true; from: Status; to: Status }
| { success: false; from: Status; to: Status; reason: string };
function transition(current: Status, next: Status): TransitionResult {
const allowed = validTransitions[current];
if (allowed.includes(next)) {
return { success: true, from: current, to: next };
}
return {
success: false,
from: current,
to: next,
reason: `Cannot move from "${current}" to "${next}". Allowed: [${allowed.join(", ")}]`
};
}
// Test transitions
const tests: [Status, Status][] = [
["todo", "in_progress"],
["in_progress", "review"],
["review", "done"],
["todo", "done"], // Invalid
["done", "todo"], // Invalid
["review", "in_progress"], // Valid — send back
];
tests.forEach(([from, to]) => {
const result = transition(from, to);
if (result.success) {
console.log(`OK: ${result.from} -> ${result.to}`);
} else {
console.log(`BLOCKED: ${result.reason}`);
}
});
// Track history
interface TaskHistory {
taskId: string;
transitions: Array<{ from: Status; to: Status; timestamp: number }>;
}
const history: TaskHistory = { taskId: "task_1", transitions: [] };
function moveTask(h: TaskHistory, current: Status, next: Status): Status {
const result = transition(current, next);
if (result.success) {
h.transitions.push({ from: current, to: next, timestamp: Date.now() });
return next;
}
return current;
}
let status: Status = "todo";
status = moveTask(history, status, "in_progress");
status = moveTask(history, status, "review");
status = moveTask(history, status, "done");
console.log(`\nFinal status: ${status}`);
console.log(`Transitions: ${history.transitions.length}`);
history.transitions.forEach(t => console.log(` ${t.from} -> ${t.to}`));
Event System
Type-safe events for tracking task changes.
// Event types using discriminated unions
type TaskEvent =
| { type: "created"; taskId: string; title: string }
| { type: "updated"; taskId: string; fields: string[] }
| { type: "assigned"; taskId: string; assignee: string }
| { type: "moved"; taskId: string; from: string; to: string }
| { type: "completed"; taskId: string; duration: number };
// Type-safe event bus
type EventHandler<T extends TaskEvent["type"]> =
(event: Extract<TaskEvent, { type: T }>) => void;
class EventBus {
private handlers = new Map<string, Array<(event: any) => void>>();
on<T extends TaskEvent["type"]>(type: T, handler: EventHandler<T>): void {
const list = this.handlers.get(type) ?? [];
list.push(handler);
this.handlers.set(type, list);
}
emit(event: TaskEvent): void {
const list = this.handlers.get(event.type) ?? [];
list.forEach(fn => fn(event));
}
}
const bus = new EventBus();
// Register handlers
bus.on("created", (e) => {
console.log(`[Created] Task "${e.title}" (${e.taskId})`);
});
bus.on("moved", (e) => {
console.log(`[Moved] ${e.taskId}: ${e.from} -> ${e.to}`);
});
bus.on("assigned", (e) => {
console.log(`[Assigned] ${e.taskId} -> ${e.assignee}`);
});
bus.on("completed", (e) => {
console.log(`[Completed] ${e.taskId} in ${e.duration}ms`);
});
// Emit events
bus.emit({ type: "created", taskId: "t1", title: "Build API" });
bus.emit({ type: "assigned", taskId: "t1", assignee: "Alice" });
bus.emit({ type: "moved", taskId: "t1", from: "todo", to: "in_progress" });
bus.emit({ type: "moved", taskId: "t1", from: "in_progress", to: "review" });
bus.emit({ type: "moved", taskId: "t1", from: "review", to: "done" });
bus.emit({ type: "completed", taskId: "t1", duration: 3600000 });
Try It Yourself
// Putting it all together: Task board with filtering and reporting
type Priority = "low" | "medium" | "high" | "urgent";
type Status = "todo" | "in_progress" | "review" | "done";
interface Task {
id: string;
title: string;
priority: Priority;
status: Status;
assignee: string | null;
tags: string[];
}
// Build the board
const board: Task[] = [
{ id: "1", title: "Setup project", priority: "high", status: "done", assignee: "alice", tags: ["setup"] },
{ id: "2", title: "Design database", priority: "high", status: "done", assignee: "bob", tags: ["backend", "database"] },
{ id: "3", title: "Build API", priority: "urgent", status: "in_progress", assignee: "alice", tags: ["backend", "api"] },
{ id: "4", title: "Auth flow", priority: "urgent", status: "in_progress", assignee: "charlie", tags: ["backend", "security"] },
{ id: "5", title: "Landing page", priority: "medium", status: "review", assignee: "bob", tags: ["frontend"] },
{ id: "6", title: "Dashboard", priority: "medium", status: "todo", assignee: "bob", tags: ["frontend"] },
{ id: "7", title: "User settings", priority: "low", status: "todo", assignee: null, tags: ["frontend"] },
{ id: "8", title: "Write docs", priority: "low", status: "todo", assignee: null, tags: ["docs"] },
];
// Display board by status columns
const columns: Status[] = ["todo", "in_progress", "review", "done"];
columns.forEach(status => {
const tasks = board.filter(t => t.status === status);
console.log(`\n${status.toUpperCase()} (${tasks.length}):`);
tasks.forEach(t => {
const assignee = t.assignee ?? "unassigned";
console.log(` [${t.priority}] ${t.title} (${assignee})`);
});
});
// Team workload
const team = ["alice", "bob", "charlie"];
console.log("\nWorkload:");
team.forEach(member => {
const active = board.filter(t => t.assignee === member && t.status !== "done");
console.log(` ${member}: ${active.length} active tasks`);
});
// Unassigned tasks
const unassigned = board.filter(t => t.assignee === null);
console.log(` unassigned: ${unassigned.length} tasks`);
// Progress
const done = board.filter(t => t.status === "done").length;
console.log(`\nProgress: ${done}/${board.length} (${Math.round(done/board.length*100)}%)`);
Key Takeaways
- Branded types prevent mixing IDs and other values with the same underlying type
- Discriminated unions model state machines with exhaustive compile-time checks
OmitandPickcreate input types from your domain model without duplication- Generic stores with typed query methods make data access type-safe
- Event systems use
Extractand mapped types for type-safe pub/sub - Transition validation ensures invalid state changes are caught before runtime
Pro Tip: Start every project by defining your domain types first. Get the
Task,Status,Priority, and event types right, and TypeScript will guide you through the implementation — every function that handles these types will be checked for correctness. Types are your specification, and the compiler is your first test suite.
Next Steps
The capstone used string unions for status and priority values. But TypeScript has a dedicated feature for named constants: enums. Next, you'll learn about numeric enums, string enums, and the as const alternative — and when to pick each one.
Next lesson
Enums And Constants
Learn TypeScript enums and const assertions to create named, type-safe constants. Replace magic strings and numbers in your code.
25 min