Lesson 4.6: Practice - TodoList Class
Duration: 70 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Apply all class concepts to build a real application
- Design class interfaces with proper typing
- Use access modifiers effectively
- Implement CRUD operations in a class
- Add filtering and searching capabilities
Project Overview
You will build a complete TodoList application that:
- Manages todo items with titles, completion status, and due dates
- Supports adding, removing, updating, and toggling todos
- Filters todos by status (all, active, completed)
- Searches todos by title
- Tracks statistics about todos
This project combines everything from Module 4:
- Functions with typed parameters and return types
- Classes with properties and methods
- Access modifiers and encapsulation
- Getters for computed properties
Step 1: Define the Todo Interface
First, define the shape of a todo item:
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
dueDate?: Date;
}
Each todo has:
- A unique
idfor identification - A
titledescribing the task - A
completedflag for status - A
createdAttimestamp - An optional
dueDatefor deadlines
Step 2: Create the TodoList Class
Basic Structure
class TodoList {
private todos: Todo[] = [];
private nextId: number = 1;
constructor(private name: string = 'My Todos') {}
}
Adding Todos
class TodoList {
private todos: Todo[] = [];
private nextId: number = 1;
constructor(private name: string = 'My Todos') {}
add(title: string, dueDate?: Date): Todo {
const todo: Todo = {
id: this.nextId++,
title: title.trim(),
completed: false,
createdAt: new Date(),
dueDate,
};
this.todos.push(todo);
return todo;
}
}
// Usage
const list = new TodoList();
const todo1 = list.add('Learn TypeScript');
const todo2 = list.add('Build a project', new Date('2024-12-31'));
Removing Todos
class TodoList {
// ... previous code
remove(id: number): boolean {
const index = this.todos.findIndex((todo) => todo.id === id);
if (index === -1) {
return false; // Todo not found
}
this.todos.splice(index, 1);
return true;
}
clear(): void {
this.todos = [];
}
}
// Usage
list.remove(1); // Removes todo with id 1
list.clear(); // Removes all todos
Toggling Completion
class TodoList {
// ... previous code
toggle(id: number): boolean {
const todo = this.findById(id);
if (!todo) {
return false;
}
todo.completed = !todo.completed;
return true;
}
private findById(id: number): Todo | undefined {
return this.todos.find((todo) => todo.id === id);
}
}
// Usage
list.add('Task 1');
list.toggle(1); // Mark as completed
list.toggle(1); // Mark as not completed
Updating Todos
interface TodoUpdate {
title?: string;
dueDate?: Date | null; // null to remove due date
}
class TodoList {
// ... previous code
update(id: number, updates: TodoUpdate): boolean {
const todo = this.findById(id);
if (!todo) {
return false;
}
if (updates.title !== undefined) {
todo.title = updates.title.trim();
}
if (updates.dueDate !== undefined) {
todo.dueDate = updates.dueDate || undefined;
}
return true;
}
}
// Usage
list.add('Old title');
list.update(1, { title: 'New title' });
list.update(1, { dueDate: new Date('2024-06-15') });
list.update(1, { dueDate: null }); // Remove due date
Step 3: Filtering and Searching
Get All Todos
class TodoList {
// ... previous code
getAll(): Todo[] {
return [...this.todos]; // Return a copy
}
getById(id: number): Todo | undefined {
const todo = this.findById(id);
return todo ? { ...todo } : undefined; // Return a copy
}
}
Filter by Status
type FilterType = 'all' | 'active' | 'completed';
class TodoList {
// ... previous code
filter(type: FilterType): Todo[] {
switch (type) {
case 'all':
return [...this.todos];
case 'active':
return this.todos.filter((todo) => !todo.completed);
case 'completed':
return this.todos.filter((todo) => todo.completed);
}
}
}
// Usage
const active = list.filter('active');
const completed = list.filter('completed');
Search by Title
class TodoList {
// ... previous code
search(query: string): Todo[] {
const searchTerm = query.toLowerCase().trim();
if (!searchTerm) {
return [...this.todos];
}
return this.todos.filter((todo) => todo.title.toLowerCase().includes(searchTerm));
}
}
// Usage
list.add('Learn TypeScript');
list.add('Learn JavaScript');
list.add('Build a project');
const results = list.search('learn');
// Returns todos with "Learn TypeScript" and "Learn JavaScript"
Get Overdue Todos
class TodoList {
// ... previous code
getOverdue(): Todo[] {
const now = new Date();
return this.todos.filter((todo) => {
if (todo.completed || !todo.dueDate) {
return false;
}
return todo.dueDate < now;
});
}
}
Step 4: Statistics with Getters
class TodoList {
// ... previous code
get count(): number {
return this.todos.length;
}
get activeCount(): number {
return this.todos.filter((todo) => !todo.completed).length;
}
get completedCount(): number {
return this.todos.filter((todo) => todo.completed).length;
}
get completionPercentage(): number {
if (this.todos.length === 0) {
return 0;
}
return Math.round((this.completedCount / this.todos.length) * 100);
}
get isEmpty(): boolean {
return this.todos.length === 0;
}
}
// Usage
console.log(`Total: ${list.count}`);
console.log(`Active: ${list.activeCount}`);
console.log(`Completed: ${list.completedCount}`);
console.log(`Progress: ${list.completionPercentage}%`);
Step 5: Bulk Operations
class TodoList {
// ... previous code
completeAll(): void {
this.todos.forEach((todo) => {
todo.completed = true;
});
}
uncompleteAll(): void {
this.todos.forEach((todo) => {
todo.completed = false;
});
}
clearCompleted(): number {
const beforeCount = this.todos.length;
this.todos = this.todos.filter((todo) => !todo.completed);
return beforeCount - this.todos.length;
}
}
// Usage
list.completeAll(); // Mark all as done
list.uncompleteAll(); // Mark all as not done
const removed = list.clearCompleted(); // Remove all completed
console.log(`Removed ${removed} completed todos`);
Complete Implementation
Here is the full TodoList class:
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
dueDate?: Date;
}
interface TodoUpdate {
title?: string;
dueDate?: Date | null;
}
type FilterType = 'all' | 'active' | 'completed';
class TodoList {
private todos: Todo[] = [];
private nextId: number = 1;
constructor(private name: string = 'My Todos') {}
// Create
add(title: string, dueDate?: Date): Todo {
if (!title.trim()) {
throw new Error('Title cannot be empty');
}
const todo: Todo = {
id: this.nextId++,
title: title.trim(),
completed: false,
createdAt: new Date(),
dueDate,
};
this.todos.push(todo);
return { ...todo };
}
// Read
getAll(): Todo[] {
return this.todos.map((todo) => ({ ...todo }));
}
getById(id: number): Todo | undefined {
const todo = this.findById(id);
return todo ? { ...todo } : undefined;
}
// Update
update(id: number, updates: TodoUpdate): boolean {
const todo = this.findById(id);
if (!todo) {
return false;
}
if (updates.title !== undefined) {
const newTitle = updates.title.trim();
if (!newTitle) {
throw new Error('Title cannot be empty');
}
todo.title = newTitle;
}
if (updates.dueDate !== undefined) {
todo.dueDate = updates.dueDate || undefined;
}
return true;
}
toggle(id: number): boolean {
const todo = this.findById(id);
if (!todo) {
return false;
}
todo.completed = !todo.completed;
return true;
}
// Delete
remove(id: number): boolean {
const index = this.todos.findIndex((todo) => todo.id === id);
if (index === -1) {
return false;
}
this.todos.splice(index, 1);
return true;
}
clear(): void {
this.todos = [];
}
// Filtering
filter(type: FilterType): Todo[] {
let filtered: Todo[];
switch (type) {
case 'all':
filtered = this.todos;
break;
case 'active':
filtered = this.todos.filter((todo) => !todo.completed);
break;
case 'completed':
filtered = this.todos.filter((todo) => todo.completed);
break;
}
return filtered.map((todo) => ({ ...todo }));
}
search(query: string): Todo[] {
const searchTerm = query.toLowerCase().trim();
if (!searchTerm) {
return this.getAll();
}
return this.todos
.filter((todo) => todo.title.toLowerCase().includes(searchTerm))
.map((todo) => ({ ...todo }));
}
getOverdue(): Todo[] {
const now = new Date();
return this.todos
.filter((todo) => !todo.completed && todo.dueDate && todo.dueDate < now)
.map((todo) => ({ ...todo }));
}
// Bulk operations
completeAll(): void {
this.todos.forEach((todo) => {
todo.completed = true;
});
}
uncompleteAll(): void {
this.todos.forEach((todo) => {
todo.completed = false;
});
}
clearCompleted(): number {
const beforeCount = this.todos.length;
this.todos = this.todos.filter((todo) => !todo.completed);
return beforeCount - this.todos.length;
}
// Statistics
get listName(): string {
return this.name;
}
get count(): number {
return this.todos.length;
}
get activeCount(): number {
return this.todos.filter((todo) => !todo.completed).length;
}
get completedCount(): number {
return this.todos.filter((todo) => todo.completed).length;
}
get completionPercentage(): number {
if (this.todos.length === 0) {
return 0;
}
return Math.round((this.completedCount / this.todos.length) * 100);
}
get isEmpty(): boolean {
return this.todos.length === 0;
}
// Private helpers
private findById(id: number): Todo | undefined {
return this.todos.find((todo) => todo.id === id);
}
// Display
toString(): string {
if (this.isEmpty) {
return `${this.name}: No todos`;
}
const lines = [`${this.name} (${this.completedCount}/${this.count} completed)`, '─'.repeat(40)];
for (const todo of this.todos) {
const status = todo.completed ? '[x]' : '[ ]';
const due = todo.dueDate ? ` (due: ${todo.dueDate.toLocaleDateString()})` : '';
lines.push(`${status} ${todo.title}${due}`);
}
return lines.join('\n');
}
}
Usage Examples
// Create a new todo list
const todoList = new TodoList('Work Tasks');
// Add some todos
todoList.add('Review pull request');
todoList.add('Fix bug in login', new Date('2024-01-15'));
todoList.add('Write documentation');
todoList.add('Deploy to staging', new Date('2024-01-20'));
console.log(todoList.toString());
// Work Tasks (0/4 completed)
// ────────────────────────────────────────
// [ ] Review pull request
// [ ] Fix bug in login (due: 1/15/2024)
// [ ] Write documentation
// [ ] Deploy to staging (due: 1/20/2024)
// Complete some todos
todoList.toggle(1);
todoList.toggle(3);
console.log(`Progress: ${todoList.completionPercentage}%`);
// Progress: 50%
// Filter active todos
const activeTodos = todoList.filter('active');
console.log(`Active todos: ${activeTodos.length}`);
// Active todos: 2
// Search todos
const searchResults = todoList.search('bug');
console.log(`Found: ${searchResults.length} todos matching "bug"`);
// Found: 1 todos matching "bug"
// Update a todo
todoList.update(2, { title: 'Fix critical bug in login' });
// Clear completed todos
const removed = todoList.clearCompleted();
console.log(`Removed ${removed} completed todos`);
// Removed 2 completed todos
console.log(todoList.toString());
// Work Tasks (0/2 completed)
// ────────────────────────────────────────
// [ ] Fix critical bug in login (due: 1/15/2024)
// [ ] Deploy to staging (due: 1/20/2024)
Exercises
Exercise 1: Add Priority
Extend the Todo interface and TodoList class to support priorities:
// Add priority: "low" | "medium" | "high" to Todo
// Add ability to set priority when creating
// Add method to sort by priority
// Add method to filter by priority
Solution
type Priority = 'low' | 'medium' | 'high';
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
dueDate?: Date;
priority: Priority;
}
class TodoList {
private todos: Todo[] = [];
private nextId: number = 1;
constructor(private name: string = 'My Todos') {}
add(title: string, priority: Priority = 'medium', dueDate?: Date): Todo {
const todo: Todo = {
id: this.nextId++,
title: title.trim(),
completed: false,
createdAt: new Date(),
dueDate,
priority,
};
this.todos.push(todo);
return { ...todo };
}
filterByPriority(priority: Priority): Todo[] {
return this.todos.filter((todo) => todo.priority === priority).map((todo) => ({ ...todo }));
}
sortByPriority(): Todo[] {
const priorityOrder: Record<Priority, number> = {
high: 0,
medium: 1,
low: 2,
};
return [...this.todos]
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
.map((todo) => ({ ...todo }));
}
// ... rest of methods
}
// Usage
const list = new TodoList();
list.add('Urgent task', 'high');
list.add('Normal task', 'medium');
list.add('Later task', 'low');
console.log(list.filterByPriority('high'));
console.log(list.sortByPriority());
Exercise 2: Add Tags
Add support for tags/categories:
// Add tags: string[] to Todo
// Add method addTag(id, tag)
// Add method removeTag(id, tag)
// Add method filterByTag(tag)
// Add method getAllTags() - returns unique tags
Solution
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
dueDate?: Date;
tags: string[];
}
class TodoList {
private todos: Todo[] = [];
private nextId: number = 1;
add(title: string, dueDate?: Date): Todo {
const todo: Todo = {
id: this.nextId++,
title: title.trim(),
completed: false,
createdAt: new Date(),
dueDate,
tags: [],
};
this.todos.push(todo);
return { ...todo };
}
addTag(id: number, tag: string): boolean {
const todo = this.todos.find((t) => t.id === id);
if (!todo) return false;
const normalizedTag = tag.toLowerCase().trim();
if (!todo.tags.includes(normalizedTag)) {
todo.tags.push(normalizedTag);
}
return true;
}
removeTag(id: number, tag: string): boolean {
const todo = this.todos.find((t) => t.id === id);
if (!todo) return false;
const normalizedTag = tag.toLowerCase().trim();
const index = todo.tags.indexOf(normalizedTag);
if (index > -1) {
todo.tags.splice(index, 1);
return true;
}
return false;
}
filterByTag(tag: string): Todo[] {
const normalizedTag = tag.toLowerCase().trim();
return this.todos
.filter((todo) => todo.tags.includes(normalizedTag))
.map((todo) => ({ ...todo }));
}
getAllTags(): string[] {
const tagSet = new Set<string>();
for (const todo of this.todos) {
for (const tag of todo.tags) {
tagSet.add(tag);
}
}
return Array.from(tagSet).sort();
}
}
// Usage
const list = new TodoList();
const todo1 = list.add('Learn TypeScript');
const todo2 = list.add('Build project');
list.addTag(todo1.id, 'learning');
list.addTag(todo1.id, 'programming');
list.addTag(todo2.id, 'programming');
list.addTag(todo2.id, 'work');
console.log(list.getAllTags()); // ["learning", "programming", "work"]
console.log(list.filterByTag('programming')); // Both todos
Exercise 3: Add Subtasks
Add support for subtasks (nested todos):
// Add subtasks: Todo[] to Todo
// Add method addSubtask(parentId, title)
// Add method toggleSubtask(parentId, subtaskId)
// Modify completion percentage to consider subtasks
Solution
interface SubTask {
id: number;
title: string;
completed: boolean;
}
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
dueDate?: Date;
subtasks: SubTask[];
}
class TodoList {
private todos: Todo[] = [];
private nextId: number = 1;
private nextSubtaskId: number = 1;
add(title: string, dueDate?: Date): Todo {
const todo: Todo = {
id: this.nextId++,
title: title.trim(),
completed: false,
createdAt: new Date(),
dueDate,
subtasks: [],
};
this.todos.push(todo);
return { ...todo };
}
addSubtask(parentId: number, title: string): SubTask | null {
const parent = this.todos.find((t) => t.id === parentId);
if (!parent) return null;
const subtask: SubTask = {
id: this.nextSubtaskId++,
title: title.trim(),
completed: false,
};
parent.subtasks.push(subtask);
return { ...subtask };
}
toggleSubtask(parentId: number, subtaskId: number): boolean {
const parent = this.todos.find((t) => t.id === parentId);
if (!parent) return false;
const subtask = parent.subtasks.find((s) => s.id === subtaskId);
if (!subtask) return false;
subtask.completed = !subtask.completed;
// Auto-complete parent if all subtasks done
if (parent.subtasks.length > 0) {
parent.completed = parent.subtasks.every((s) => s.completed);
}
return true;
}
get completionPercentage(): number {
let totalTasks = 0;
let completedTasks = 0;
for (const todo of this.todos) {
if (todo.subtasks.length > 0) {
totalTasks += todo.subtasks.length;
completedTasks += todo.subtasks.filter((s) => s.completed).length;
} else {
totalTasks += 1;
if (todo.completed) completedTasks += 1;
}
}
if (totalTasks === 0) return 0;
return Math.round((completedTasks / totalTasks) * 100);
}
}
// Usage
const list = new TodoList();
const project = list.add('Complete project');
list.addSubtask(project.id, 'Design');
list.addSubtask(project.id, 'Develop');
list.addSubtask(project.id, 'Test');
list.toggleSubtask(project.id, 1); // Complete Design
console.log(list.completionPercentage); // 33%
list.toggleSubtask(project.id, 2); // Complete Develop
list.toggleSubtask(project.id, 3); // Complete Test
console.log(list.completionPercentage); // 100%
Key Takeaways
- Classes organize related data and behavior: TodoList bundles todo storage with operations
- Private members hide implementation: Internal array and ID counter are hidden
- Getters provide computed values: Statistics are calculated on demand
- Methods encapsulate operations: Add, remove, toggle are controlled actions
- Return copies for safety: Return
{...todo}instead of direct references - Validation in methods: Check inputs before modifying state
Resources
| Resource | Type | Description |
|---|---|---|
| TypeScript Handbook: Classes | Documentation | Complete class reference |
| TodoMVC | Examples | Todo app implementations |
| TypeScript Playground | Tool | Try the code online |
Module Complete
Congratulations! You have completed Module 4: Functions and Classes. You have learned:
- How to write type-safe functions with typed parameters and return types
- Arrow function syntax and when to use it
- Creating classes with properties, methods, and constructors
- Using access modifiers and inheritance
- Building a real-world TodoList application
You are now ready to learn about generics and utility types in Module 5.