← thecodex.expert · The Codex Family of Knowledge
Tier 1 · Beginner · TypeScript Project

To-Do List

Add, view, and remove tasks — and save them so they are still there next time. Your first app that remembers things between runs.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every app that stores your stuff — notes, reminders, shopping lists, saved games, settings. The pattern of “keep a list in memory, save it to a file, load it back next time” is the simplest form of a database.

2 How to Think About It

Think about the loop the program lives in, before any code:

The plan — in plain English
1. Load any saved tasks from a file when the program starts. → 2. Show a menu (add / view / remove / quit) and repeat until they quit. → 3. Each choice changes the list. → 4. Save the list back to the file whenever it changes, so nothing is lost.

Add

View

Remove

Quit

Load tasks from file

Show menu

Choice?

Add task

Show tasks

Remove task

Stop

Save to file

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.

TypeScripttodo.ts
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();
}
⚠ No in-browser playground here
Running real, type-checked TypeScript in the browser needs either a full copy of the compiler or a third-party CDN script — the same kind of external dependency this site avoids relying on for a core teaching example. Copy the code below and run it with Node on your own machine instead; the “Run It” section explains exactly how.
What each part does — in plain words
function loadTasks(): string[] — reads 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.
Common mistakes — and how to avoid them
✗ Asserting as string[] on JSON.parse's result without actually checking the file’s shape.
✓ A type assertion is a promise to the compiler, not a runtime check. If 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.
✗ Calling saveTasks even when “Remove” was given a number outside the list’s range.
✓ Only remove (and only save) when the number is actually 1 ≤ number ≤ tasks.length — otherwise you write an unchanged file for no reason, matching the Python version’s behaviour exactly.
✗ Looping forever on an exhausted input source when the menu is driven from a piped file instead of a keyboard.
✓ Check for 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.

Adding a task puts it in the list
Removing task 1 takes the first item out
Removing a number that does not exist leaves the list unchanged
TypeScripttodo.test.ts
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

INPUTmenu choice1-4, then task text or number
What it expects
Choose: 1
New task: Walk the dog
OUTPUTtask listnumbered, saved to tasks.json
What it returns
1. Walk the dog
2. Buy milk

6 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.

Run it locally
npx tsc todo.ts && node todo.js
Add 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.

What you should see when it works
Terminala real run
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 milk
If it breaks — how to fix it
🚨 SyntaxError: Unexpected end of JSON input when the program starts
tasks.json exists but is empty (for example, the program was killed mid-write). Delete the file and let the program recreate it.
🚨 A typed task never appears when choosing “View”
Make sure “Add” actually calls saveTasks after addTask — otherwise the in-memory list and the file drift apart.
GroovyJenkinsfile
// Jenkinsfile &mdash; 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 &mdash; 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 &mdash; look above.' }
    }
}
🎯 Try this next — make it yours

You have a working to-do list. Extend it:

  1. 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.)
  2. Add due dates. Store an optional date per task. (Teaches: optional properties, date?: string.)
  3. Validate the saved file. Before trusting JSON.parse's result, check it is really an array. (Teaches: runtime type guards vs. compile-time types.)
  4. Sort by priority. Let each task carry a priority and sort the view. (Teaches: Array.prototype.sort with a comparator.)
What you learned
You learned how a type assertion (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.