10 · Project — Typed CLI To-Do App¶
🎥 Video walkthrough¶
A small end-to-end project combining everything from Level 1: interfaces, typed arrays/objects, classes, enums, and modules.
What you'll build¶
A command-line to-do list that:
- Adds tasks
- Lists tasks (with done/pending status)
- Marks tasks done
- Deletes tasks
- Persists everything to a JSON file between runs
Project layout¶
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
}
}
storage.ts — persistence layer¶
// storage.ts
import * as fs from "fs";
export interface Task {
description: string;
done: boolean;
}
const DB_PATH = "tasks.json";
export function loadTasks(): Task[] {
if (!fs.existsSync(DB_PATH)) {
return [];
}
try {
const contents = fs.readFileSync(DB_PATH, "utf-8");
return JSON.parse(contents) as Task[];
} catch {
return [];
}
}
export function saveTasks(tasks: Task[]): void {
fs.writeFileSync(DB_PATH, JSON.stringify(tasks, null, 2));
}
todo.ts — CLI logic¶
// todo.ts
import { Task, loadTasks, saveTasks } from "./storage";
function addTask(tasks: Task[], description: string): void {
tasks.push({ description, done: false });
console.log(`Added: ${description}`);
}
function listTasks(tasks: Task[]): void {
if (tasks.length === 0) {
console.log("No tasks yet.");
return;
}
tasks.forEach((task, i) => {
const status = task.done ? "x" : " ";
console.log(`[${status}] ${i + 1}. ${task.description}`);
});
}
function completeTask(tasks: Task[], index: number): void {
const task = tasks[index - 1];
if (!task) {
console.log(`No task with number ${index}`);
return;
}
task.done = true;
console.log(`Marked task ${index} done.`);
}
function deleteTask(tasks: Task[], index: number): void {
if (index < 1 || index > tasks.length) {
console.log(`No task with number ${index}`);
return;
}
const [removed] = tasks.splice(index - 1, 1);
console.log(`Deleted: ${removed.description}`);
}
function main(): void {
const tasks = loadTasks();
const args = process.argv.slice(2);
if (args.length === 0) {
console.log("Usage: ts-node todo.ts [add <text> | list | done <n> | delete <n>]");
return;
}
const [command, ...rest] = args;
switch (command) {
case "add":
addTask(tasks, rest.join(" "));
saveTasks(tasks);
break;
case "list":
listTasks(tasks);
break;
case "done":
completeTask(tasks, Number(rest[0]));
saveTasks(tasks);
break;
case "delete":
deleteTask(tasks, Number(rest[0]));
saveTasks(tasks);
break;
default:
console.log(`Unknown command: ${command}`);
}
}
main();
How It Actually Works¶
This project ties together type erasure and structural typing in a way worth naming explicitly. The Todo interface you define exists purely for the compiler — when todo.ts is compiled, every : Todo annotation, and the interface Todo { ... } declaration itself, is deleted; the emitted JS reading and writing todos.json has no notion of a "Todo shape" at all. That means the actual safety net is entirely at compile time: if the JSON file on disk is missing a field or has a done value stored as the string "true" instead of the boolean true, JSON.parse will happily hand back an object typed as Todo (because you told the checker to trust that shape via a type assertion or annotation on the parse result) even though it doesn't structurally match — this is a case where the checker takes your word for it rather than verifying, since JSON.parse's real return type is any.
The CLI's argument parsing (process.argv) is a good example of where TypeScript's structural narrowing runs out of information: process.argv is typed string[], and everything you pull out of it stays string until you narrow it yourself — parsing a todo id from a CLI argument requires an explicit runtime check (Number(arg) and validating !isNaN(...)) because the compiler has no way to verify at compile time that whatever the user actually types on the command line matches your expected shape; type safety only covers what's expressible about code, never what's true about live, external input.
Running it¶
npx ts-node todo.ts add "Write Level 1 exercises"
npx ts-node todo.ts add "Review Level 2 outline"
npx ts-node todo.ts list
# [ ] 1. Write Level 1 exercises
# [ ] 2. Review Level 2 outline
npx ts-node todo.ts done 1
npx ts-node todo.ts list
# [x] 1. Write Level 1 exercises
# [ ] 2. Review Level 2 outline
Stretch goals¶
- Add a
Priorityenum (Low/Medium/High) as a field onTaskand sort the list by it. - Add a
dueDate?: stringoptional field and highlight overdue tasks. - Add a Jest test for
storage.ts(you'll formalize this properly with Jest in Level 2).
Completing this project means you're ready for Level 2 · Intermediate.