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 --initUpdate 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 valuesTask— the main interface defining a task's shapeTaskUpdate— usePartialto make all fields optional,Omitto 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/promisesfor 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
tasksarray — only the class can access it directly findByIdreturnsTask | undefined— type-safe null handlinglistwith optionalstatusparameter for filteringupdateusesTaskUpdatetype (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.argvgives command line arguments- Pattern matching with
switchfor command routing - Type assertion
as TaskStatuswhen we know the runtime value matches - Check
result.successto 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 2Step 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 & Types —
Task,TaskStatus,TaskUpdate,Result<T> - Union Types — status values, discriminated union for results
- Utility Types —
Partial<T>andOmit<T>to deriveTaskUpdate - Generics —
Result<T>works with any data type - Classes —
TaskManagerencapsulates task operations - Type Guards — checking
result.successto narrow the type - Optional Parameters —
list(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
completecommand as shorthand forupdate <id> status completed - Export tasks to CSV or markdown
- Add task dependencies: "Task B can't start until Task A is done"