From Zero to AI

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 id for identification
  • A title describing the task
  • A completed flag for status
  • A createdAt timestamp
  • An optional dueDate for 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

  1. Classes organize related data and behavior: TodoList bundles todo storage with operations
  2. Private members hide implementation: Internal array and ID counter are hidden
  3. Getters provide computed values: Statistics are calculated on demand
  4. Methods encapsulate operations: Add, remove, toggle are controlled actions
  5. Return copies for safety: Return {...todo} instead of direct references
  6. 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.

Continue to Module 5: Generics and Utilities