07 - Build a CLI Task Manager

Setup

Create a new directory and initialize a TypeScript project:

mkdir task-manager
cd task-manager
npm init -y
npm install typescript @types/node tsx
npx tsc --init

Update tsconfig.json — set "target": "ES2020" and "module": "ESNext".

Step 1: Define Types

Create src/types.ts:

export type TaskStatus = "pending" | "in-progress" | "completed";

export interface Task {
  id: number;
  title: string;
  description: string;
  status: TaskStatus;
  createdAt: Date;
  updatedAt: Date;
}

export type TaskUpdate = Partial<Omit<Task, "id" | "createdAt" | "updatedAt">>;

export type Result<T> =
  | { success: true; data: T }
  | { success: false; error: string };

Key concepts:

  • TaskStatus — union type for fixed status values
  • Task — the main interface defining a task's shape
  • TaskUpdate — use Partial to make all fields optional, Omit to exclude id and timestamps (can't update those)
  • Result<T> — discriminated union for type-safe error handling

Step 2: Storage Layer

Create src/storage.ts:

import fs from "node:fs/promises";
import path from "node:path";
import type { Task } from "./types.js";

const DATA_FILE = path.join(process.cwd(), "tasks.json");

export async function loadTasks(): Promise<Task[]> {
  try {
    const data = await fs.readFile(DATA_FILE, "utf-8");
    const tasks = JSON.parse(data);
    // Convert date strings back to Date objects
    return tasks.map((t: any) => ({
      ...t,
      createdAt: new Date(t.createdAt),
      updatedAt: new Date(t.updatedAt),
    }));
  } catch {
    return []; // File doesn't exist yet, return empty array
  }
}

export async function saveTasks(tasks: Task[]): Promise<void> {
  await fs.writeFile(DATA_FILE, JSON.stringify(tasks, null, 2));
}

Key concepts:

  • Use node:fs/promises for async file operations
  • Type the return value explicitly: Promise<Task[]>
  • JSON doesn't preserve Date objects, so we manually restore them on load

Step 3: Task Manager Class

Create src/TaskManager.ts:

import type { Task, TaskStatus, TaskUpdate, Result } from "./types.js";
import { loadTasks, saveTasks } from "./storage.js";

export class TaskManager {
  private tasks: Task[] = [];

  async load(): Promise<void> {
    this.tasks = await loadTasks();
  }

  async save(): Promise<void> {
    await saveTasks(this.tasks);
  }

  create(title: string, description: string): Task {
    const task: Task = {
      id: Date.now(),
      title,
      description,
      status: "pending",
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    this.tasks.push(task);
    return task;
  }

  findById(id: number): Task | undefined {
    return this.tasks.find((t) => t.id === id);
  }

  list(status?: TaskStatus): Task[] {
    if (status) {
      return this.tasks.filter((t) => t.status === status);
    }
    return [...this.tasks]; // return a copy
  }

  update(id: number, updates: TaskUpdate): Result<Task> {
    const task = this.findById(id);
    if (!task) {
      return { success: false, error: "Task not found" };
    }

    Object.assign(task, updates, { updatedAt: new Date() });
    return { success: true, data: task };
  }

  delete(id: number): Result<void> {
    const index = this.tasks.findIndex((t) => t.id === id);
    if (index === -1) {
      return { success: false, error: "Task not found" };
    }

    this.tasks.splice(index, 1);
    return { success: true, data: undefined };
  }
}

Key concepts:

  • Private tasks array — only the class can access it directly
  • findById returns Task | undefined — type-safe null handling
  • list with optional status parameter for filtering
  • update uses TaskUpdate type (Partial of Task without id/timestamps)
  • Result<T> return type forces callers to handle success/failure

Step 4: CLI Interface

Create src/cli.ts:

import { TaskManager } from "./TaskManager.js";
import type { TaskStatus } from "./types.js";

const manager = new TaskManager();

async function main() {
  await manager.load();

  const [, , command, ...args] = process.argv;

  switch (command) {
    case "add":
      await handleAdd(args);
      break;
    case "list":
      await handleList(args);
      break;
    case "update":
      await handleUpdate(args);
      break;
    case "delete":
      await handleDelete(args);
      break;
    default:
      console.log("Usage: tsx src/cli.ts <command> [args]");
      console.log("Commands: add, list, update, delete");
  }
}

async function handleAdd(args: string[]) {
  const [title, ...descParts] = args;
  const description = descParts.join(" ");

  if (!title) {
    console.error("Error: Title is required");
    return;
  }

  const task = manager.create(title, description || "");
  await manager.save();
  console.log(`✓ Created task #${task.id}: ${task.title}`);
}

async function handleList(args: string[]) {
  const status = args[0] as TaskStatus | undefined;
  const tasks = manager.list(status);

  if (tasks.length === 0) {
    console.log("No tasks found");
    return;
  }

  console.log("\nTasks:");
  for (const task of tasks) {
    console.log(
      `[${task.id}] ${task.title} — ${task.status} (${task.description})`
    );
  }
  console.log("");
}

async function handleUpdate(args: string[]) {
  const [idStr, field, ...valueParts] = args;
  const id = parseInt(idStr);
  const value = valueParts.join(" ");

  if (!id || !field) {
    console.error("Error: Usage: update <id> <field> <value>");
    return;
  }

  const updates: any = {};
  if (field === "status") {
    updates.status = value as TaskStatus;
  } else if (field === "title" || field === "description") {
    updates[field] = value;
  } else {
    console.error("Error: Invalid field. Use: title, description, or status");
    return;
  }

  const result = manager.update(id, updates);
  if (result.success) {
    await manager.save();
    console.log(`✓ Updated task #${id}`);
  } else {
    console.error(`Error: ${result.error}`);
  }
}

async function handleDelete(args: string[]) {
  const id = parseInt(args[0]);

  if (!id) {
    console.error("Error: Task ID is required");
    return;
  }

  const result = manager.delete(id);
  if (result.success) {
    await manager.save();
    console.log(`✓ Deleted task #${id}`);
  } else {
    console.error(`Error: ${result.error}`);
  }
}

main();

Key concepts:

  • process.argv gives command line arguments
  • Pattern matching with switch for command routing
  • Type assertion as TaskStatus when we know the runtime value matches
  • Check result.success to handle errors — discriminated union at work

Step 5: Add Scripts

Update package.json to add convenience scripts:

{
  "scripts": {
    "task": "tsx src/cli.ts"
  }
}

Step 6: Try It Out

# Add tasks
npm run task add "Learn TypeScript" "Complete the tutorial"
npm run task add "Build a project" "Apply what I learned"

# List all tasks
npm run task list

# List by status
npm run task list pending

# Update a task
npm run task update 1 status in-progress
npm run task update 1 title "Master TypeScript"

# Delete a task
npm run task delete 2

Step 7: Add a Search Feature (Bonus)

Update src/TaskManager.ts — add this method:

search(query: string): Task[] {
  const lowerQuery = query.toLowerCase();
  return this.tasks.filter(
    (t) =>
      t.title.toLowerCase().includes(lowerQuery) ||
      t.description.toLowerCase().includes(lowerQuery)
  );
}

Update src/cli.ts — add to the switch statement:

case "search":
  await handleSearch(args);
  break;

Add the handler:

async function handleSearch(args: string[]) {
  const query = args.join(" ");
  if (!query) {
    console.error("Error: Search query is required");
    return;
  }

  const tasks = manager.search(query);
  if (tasks.length === 0) {
    console.log("No matching tasks found");
    return;
  }

  console.log(`\nFound ${tasks.length} task(s):`);
  for (const task of tasks) {
    console.log(
      `[${task.id}] ${task.title} — ${task.status} (${task.description})`
    );
  }
  console.log("");
}

Try it:

npm run task search "typescript"

What You've Built

A complete CLI task manager that demonstrates:

  • Interfaces & TypesTask, TaskStatus, TaskUpdate, Result<T>
  • Union Types — status values, discriminated union for results
  • Utility TypesPartial<T> and Omit<T> to derive TaskUpdate
  • GenericsResult<T> works with any data type
  • ClassesTaskManager encapsulates task operations
  • Type Guards — checking result.success to narrow the type
  • Optional Parameterslist(status?: TaskStatus)
  • File I/O — async/await with Node.js fs module

Next Steps

  • Add date filtering: tasks created/updated within a date range
  • Add task priorities (high, medium, low)
  • Add tags/categories for better organization
  • Add a complete command as shorthand for update <id> status completed
  • Export tasks to CSV or markdown
  • Add task dependencies: "Task B can't start until Task A is done"