1 The Problem
We want a to-do list you can actually use: add tasks, see them numbered, remove the ones you finish, and — crucially — have them saved to a file so they survive after you close the program. It teaches lists, a menu loop, and saving data to disk.
2 How to Think About It
Think about the loop the program lives in, before any code:
3 The Build — explained part by part
Here is the complete to-do list. Read each part’s note below — you should understand the whole thing from the notes alone.
import * as fs from "node:fs";
import * as readline from "node:readline";
import { stdin, stdout } from "node:process";
const FILE = "tasks.json";
export function loadTasks(): string[] {
// If a saved file exists, read the tasks from it; otherwise start empty.
if (fs.existsSync(FILE)) {
return JSON.parse(fs.readFileSync(FILE, "utf-8")) as string[];
}
return [];
}
export function saveTasks(tasks: string[]): void {
// Write the tasks to the file so they survive after the program closes.
fs.writeFileSync(FILE, JSON.stringify(tasks));
}
export function addTask(tasks: string[], task: string): string[] {
tasks.push(task);
return tasks;
}
export function removeTask(tasks: string[], number: number): string[] {
if (number >= 1 && number <= tasks.length) {
tasks.splice(number - 1, 1);
}
return tasks;
}
// ask prints a prompt and waits for one line of input, pulling it from the
// readline interface's own async iterator rather than question()/promises —
// the one readline API that does not drop a line when several prompts are
// answered back-to-back, typed by hand or piped in from a file. It resolves
// to null once there is nothing left to read (stdin closed), so the menu
// loop can stop cleanly instead of spinning on an empty answer forever.
function makeAsk(rl: readline.Interface) {
const it = rl[Symbol.asyncIterator]();
return async (prompt: string): Promise<string | null> => {
stdout.write(prompt);
const { value, done } = await it.next();
return done ? null : value;
};
}
async function main(): Promise<void> {
const rl = readline.createInterface({ input: stdin, terminal: false });
const ask = makeAsk(rl);
let tasks = loadTasks();
while (true) {
console.log("\n1. Add 2. View 3. Remove 4. Quit");
const choiceLine = await ask("Choose: ");
if (choiceLine === null) break; // no more input — quit gracefully
const choice = choiceLine.trim();
if (choice === "1") {
const task = (await ask("New task: ")) ?? "";
tasks = addTask(tasks, task);
saveTasks(tasks);
} else if (choice === "2") {
tasks.forEach((task, i) => console.log(`${i + 1}. ${task}`));
} else if (choice === "3") {
const number = Number(((await ask("Remove which number? ")) ?? "").trim());
if (number >= 1 && number <= tasks.length) {
tasks = removeTask(tasks, number);
saveTasks(tasks);
}
} else if (choice === "4") {
break;
} else {
console.log("Please choose 1 to 4.");
}
}
rl.close();
}
if (require.main === module) {
main();
}tasks.json back into an array if it exists, otherwise starts empty. The as string[] after JSON.parse is a type assertion: TypeScript cannot know what shape JSON from a file will have, so we tell it what we expect — it is then our job to be right.function addTask / removeTask — pulled out of the interactive loop exactly like the Python version’s helpers, so they can be tested directly on plain arrays.
while (true) { ... } — the menu loop. Each iteration asks for a choice, and a
switch-like chain of ifs dispatches to the matching action — the same shape as the Python version’s own menu.if (choiceLine === null) break; — the same end-of-input guard used throughout this set of projects: once there is nothing left to read, stop asking instead of looping forever on an empty answer.
as string[] on JSON.parse's result without actually checking the file’s shape.tasks.json is ever hand-edited into something else, this will still compile — only a real validation step (or a library like zod) catches that.saveTasks even when “Remove” was given a number outside the list’s range.1 ≤ number ≤ tasks.length — otherwise you write an unchanged file for no reason, matching the Python version’s behaviour exactly.null (end of input) from the line reader and break, the same guard used by the number-guessing game.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
import { test } from "node:test";
import assert from "node:assert/strict";
import { addTask, removeTask } from "./to-do-list";
test("adding a task puts it in the list", () => {
assert.deepEqual(addTask([], "Buy milk"), ["Buy milk"]);
});
test("removing task 1 takes the first item out", () => {
assert.deepEqual(removeTask(["a", "b"], 1), ["b"]);
});
test("removing a number that does not exist leaves the list unchanged", () => {
assert.deepEqual(removeTask(["a"], 9), ["a"]);
});Compile with npx tsc then run node --test todo.test.js. addTask and removeTask work on a plain in-memory array, so the tests never touch tasks.json or the menu at all.
5 The Interface
What it expects
Choose: 1
New task: Walk the dogWhat it returns
1. Walk the dog
2. Buy milk6 Run It & Automate It
Save the code as todo.ts, compile with npx tsc, and run with node todo.js — or run it directly with npx tsx todo.ts.
npx tsc todo.ts && node todo.jsAdd a task, view the list, then quit — run it again and your tasks are still there.
A CI tool like Jenkins runs the type-checker and tests automatically whenever the code changes — every line below has a plain explanation.
1. Add 2. View 3. Remove 4. Quit
Choose: 1
New task: Buy milk
1. Add 2. View 3. Remove 4. Quit
Choose: 2
1. Buy milkSyntaxError: Unexpected end of JSON input when the program startstasks.json exists but is empty (for example, the program was killed mid-write). Delete the file and let the program recreate it.saveTasks after addTask — otherwise the in-memory list and the file drift apart.// Jenkinsfile — runs the type-checker and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Node') {
steps {
sh 'node --version' // confirm Node is installed
sh 'npm install -D typescript @types/node' // zero runtime deps — just the compiler and its Node types
}
}
stage('Type-check and test') {
steps {
sh 'npx tsc --noEmit' // catch type errors before anything runs
sh 'npx tsc' // compile to plain JavaScript
sh 'node --test todo.test.js' // Node's built-in test runner, no extra install needed
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working to-do list. Extend it:
- Mark tasks done instead of removing them. Store
{ text: string; done: boolean }objects, as the CLI task manager project does. (Teaches: an interface instead of a bare string.) - Add due dates. Store an optional date per task. (Teaches: optional properties,
date?: string.) - Validate the saved file. Before trusting
JSON.parse's result, check it is really an array. (Teaches: runtime type guards vs. compile-time types.) - Sort by priority. Let each task carry a priority and sort the view. (Teaches:
Array.prototype.sortwith a comparator.)
as string[]) differs from a real runtime check, how to guard a menu loop against running out of input, and how pulling the list logic into plain functions keeps it testable without any file I/O. Related reference: Interfaces vs. Types, Basic Types.