On this page
Final project: Task management system
Project overview
This final project integrates every major concept from the course into a single, cohesive TypeScript application — a Task Management System. You will see how interfaces, generics, discriminated unions, utility types, decorators, and type guards work together in a design that is both expressive and safe.
Read through the full implementation in the code panel, then use the challenges below to extend it on your own.
What the project covers
| Concept | Where it appears |
|---|---|
| Interfaces | User, Project, Task, Entity |
| Discriminated unions | TaskStatus with status as the discriminant |
| Utility types | CreateTaskDto (Omit), UpdateTaskDto (Partial + Omit), TaskSummary (Pick) |
| Generic repository | Repository<T extends Entity> with findWhere<K extends keyof T> |
| Custom type guards | isInProgress, isDone with task is ... predicate |
| TC39 method decorator | @logCall on createTask, assignTask, completeTask |
| Exhaustiveness check | default: const _exhaustive: never = task in describeTask |
| Indexed access types | TaskStatus["status"] in TaskSummary |
| Narrowing | switch (task.status) distributes the union |
Architecture walkthrough
1. Core interfaces
User, Project, and Task define the domain entities. Task deliberately separates the static task data (title, description, priority, dates, tags) from the dynamic status information. The status is a discriminated union (TaskStatus) where each member carries only the data relevant to that state.
Combining them with an intersection type FullTask = Task & TaskStatus keeps the interfaces focused and avoids duplication.
2. DTO types
Data-Transfer Objects are derived from the base interfaces using utility types, so the source of truth is always Task:
CreateTaskDto—OmitremovesidandcreatedAt(server-generated), then adds an optionalstatusstarter.UpdateTaskDto—Partial<Omit<...>>lets callers patch any subset of writable task fields.TaskSummary—Pickselects only the fields needed for a list view.
Whenever Task changes, all three derived types update automatically.
3. Generic repository
Repository<T extends Entity> provides type-safe CRUD operations for any entity that has a string id. The findWhere<K extends keyof T>(key, value) method combines the keyof constraint and indexed access types to guarantee that the key is a real field of T and the value has the exact type of that field.
4. Decorator for logging
@logCall is a TC39 method decorator that wraps any method with before/after logging. Applying it to the three main service methods provides a complete audit trail without modifying the method bodies. In a real application you would replace console.log with a structured logger.
5. Type guards in the service
isInProgress and isDone are custom type guards that use the task is ... predicate syntax. Inside completeTask, calling isInProgress(task) not only checks the condition at runtime but also narrows the type so the compiler knows task.startedAt and task.assigneeId are accessible.
6. Exhaustiveness in describeTask
The switch statement on task.status handles every member of the TaskStatus union. The default branch assigns task to a variable of type never. If you add a new status variant and forget to add a matching case, TypeScript will report a compile error — before a single test runs.
Extension challenges
These challenges push you to apply concepts independently. The playground above is your starting point.
Challenge 1 — Add a `Project` service
Create a ProjectService that uses Repository<Project>. Add a method getProjectTasks(projectId: string): FullTask[] that delegates to the TaskService and filters by projectId.
Challenge 2 — Role-based permissions
Add a canAssign(user: User, task: FullTask): boolean function that returns true only if the user's role is "admin" or "member", and the task is in "todo" or "in_progress" status. Use narrowing inside the function.
Challenge 3 — Deep partial update
The current UpdateTaskDto uses Partial<...> which is shallow. Write a DeepPartial<T> type (from lesson 12) and use it to define a DeepUpdateTaskDto that allows partial updates of nested objects within Task.
Challenge 4 — Statistics utility
Write a getProjectStats(tasks: FullTask[]) function with a return type built entirely from utility types:
type ProjectStats = Record<TaskStatus["status"], number> & {
totalTasks: number;
completionRate: number;
};Count the number of tasks in each status and compute the completion rate.
Challenge 5 — Serializable repository
Add a toJSON(): string method to Repository<T> and a static fromJSON<T extends Entity>(json: string): Repository<T> factory. Use Readonly<T> for the internal store values to prevent accidental mutation after deserialization.
Congratulations
You have completed the TypeScript Complete course. Starting from primitive types and type annotations, you have worked through:
- Compound types: arrays, tuples, enums, interfaces, type aliases, unions, intersections
- Functions and generics: typing functions, generic functions, advanced constraints with
keyofandinfer - Advanced types: narrowing, type guards, all built-in utility types, conditional and mapped types
- TypeScript in practice: classes, decorators, modules, path aliases, and incremental JS migration
- Final project: a complete, runnable application integrating all the above
The best way to consolidate this knowledge is to apply it immediately to a real project. Pick a JavaScript codebase you work with regularly and start the incremental migration process — one file at a time, one compiler flag at a time.
Happy coding.
// ============================================================
// TASK MANAGEMENT SYSTEM — Full TypeScript Project
// Covers: interfaces, generics, discriminated unions,
// utility types, decorators, type guards, narrowing
// ============================================================
// ------ 1. CORE INTERFACES ------
interface User {
readonly id: string;
name: string;
email: string;
role: "admin" | "member" | "viewer";
}
interface Project {
readonly id: string;
name: string;
description: string;
ownerId: string;
createdAt: Date;
tags: string[];
}
// Discriminated union for task status
type TaskStatus =
| { status: "todo" }
| { status: "in_progress"; startedAt: Date; assigneeId: string }
| { status: "review"; reviewerId: string }
| { status: "done"; completedAt: Date; completedBy: string }
| { status: "cancelled"; reason: string };
interface Task {
readonly id: string;
projectId: string;
title: string;
description: string;
priority: "low" | "medium" | "high" | "critical";
createdAt: Date;
dueDate: Date | null;
tags: string[];
}
type FullTask = Task & TaskStatus;
// ------ 2. DTO TYPES (utility types) ------
type CreateTaskDto = Omit<Task, "id" | "createdAt"> & {
status?: "todo";
};
type UpdateTaskDto = Partial<Omit<Task, "id" | "createdAt" | "projectId">>;
type TaskSummary = Pick<Task, "id" | "title" | "priority"> & {
status: TaskStatus["status"];
};
// ------ 3. GENERIC REPOSITORY ------
interface Entity {
readonly id: string;
}
class Repository<T extends Entity> {
private store = new Map<string, T>();
add(item: T): T {
this.store.set(item.id, item);
return item;
}
findById(id: string): T | undefined {
return this.store.get(id);
}
findAll(): T[] {
return Array.from(this.store.values());
}
update(id: string, patch: Partial<T>): T | undefined {
const existing = this.store.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...patch };
this.store.set(id, updated);
return updated;
}
remove(id: string): boolean {
return this.store.delete(id);
}
findWhere<K extends keyof T>(key: K, value: T[K]): T[] {
return this.findAll().filter(item => item[key] === value);
}
}
// ------ 4. DECORATOR: logging ------
function logCall(
target: unknown,
context: ClassMethodDecoratorContext
): unknown {
const name = String(context.name);
return function (this: unknown, ...args: unknown[]): unknown {
console.log(`[${name}] →`, args);
const result = (target as (...a: unknown[]) => unknown).apply(this, args);
console.log(`[${name}] ←`, result);
return result;
};
}
// ------ 5. TASK SERVICE WITH TYPE GUARDS ------
function isInProgress(task: FullTask): task is Task & { status: "in_progress"; startedAt: Date; assigneeId: string } {
return task.status === "in_progress";
}
function isDone(task: FullTask): task is Task & { status: "done"; completedAt: Date; completedBy: string } {
return task.status === "done";
}
class TaskService {
private tasks = new Repository<FullTask>();
private counter = 0;
private generateId(): string {
return `task-${++this.counter}`;
}
@logCall
createTask(dto: CreateTaskDto): FullTask {
const task: FullTask = {
...dto,
id: this.generateId(),
createdAt: new Date(),
status: "todo",
};
return this.tasks.add(task);
}
@logCall
assignTask(taskId: string, assigneeId: string): FullTask | undefined {
const task = this.tasks.findById(taskId);
if (!task) return undefined;
if (task.status !== "todo" && task.status !== "in_progress") return task;
const updated: FullTask = {
...task,
status: "in_progress",
startedAt: new Date(),
assigneeId,
};
return this.tasks.update(taskId, updated);
}
@logCall
completeTask(taskId: string, completedBy: string): FullTask | undefined {
const task = this.tasks.findById(taskId);
if (!task || !isInProgress(task)) return task;
const done: FullTask = {
...task,
status: "done",
completedAt: new Date(),
completedBy,
};
return this.tasks.update(taskId, done);
}
getTaskSummaries(): TaskSummary[] {
return this.tasks.findAll().map(task => ({
id: task.id,
title: task.title,
priority: task.priority,
status: task.status,
}));
}
getCompletedTasks(): Array<Task & { status: "done"; completedAt: Date; completedBy: string }> {
return this.tasks.findAll().filter(isDone);
}
describeTask(task: FullTask): string {
switch (task.status) {
case "todo":
return `[TODO] ${task.title}`;
case "in_progress":
return `[IN PROGRESS] ${task.title} — assigned to ${task.assigneeId}`;
case "review":
return `[REVIEW] ${task.title} — reviewer: ${task.reviewerId}`;
case "done":
return `[DONE] ${task.title} — completed by ${task.completedBy}`;
case "cancelled":
return `[CANCELLED] ${task.title} — reason: ${task.reason}`;
default:
const _exhaustive: never = task;
return _exhaustive;
}
}
}
// ------ 6. DEMO ------
const service = new TaskService();
const t1 = service.createTask({
projectId: "proj-1",
title: "Design database schema",
description: "ERD + migrations for the new module",
priority: "high",
dueDate: null,
tags: ["database", "architecture"],
});
const t2 = service.createTask({
projectId: "proj-1",
title: "Implement REST endpoints",
description: "CRUD for /tasks resource",
priority: "medium",
dueDate: null,
tags: ["backend", "api"],
});
service.assignTask(t1.id, "user-alice");
service.completeTask(t1.id, "user-alice");
service.assignTask(t2.id, "user-bob");
console.log("=== Task Summaries ===");
service.getTaskSummaries().forEach(s =>
console.log(` ${s.id}: [${s.priority}] ${s.title} (${s.status})`)
);
console.log("\n=== Completed Tasks ===");
service.getCompletedTasks().forEach(t =>
console.log(` ${t.title} — done at ${t.completedAt.toISOString()}`)
);
Sign in to track your progress