All Tutorials
TypeScript
Commander.js
Node.js
CLI

Build a Todo CLI with TypeScript and Commander.js

Build a persistent, typed command-line todo app from scratch with TypeScript and Commander.js. No frontend, no API, just Node.js, a local JSON store, and clean typed interfaces. You will learn how Commander.js parses subcommands and flags, how to read and write a typed JSON file with the Node.js `fs` module, and how to run TypeScript directly with `tsx` during development. Estimated time: 45 minutes.

11 min read2026-07-23

The short answer

You build a small command-line todo app with three subcommands, add, list, and done, using Commander.js to parse them and a local todos.json file for storage. Commander handles the argument parsing, help text, and errors; Node's built-in fs module reads and writes the JSON; tsx runs the TypeScript directly while you develop. Every task is a typed TodoItem, so the code that creates a task and the code that prints it agree on the exact fields. The whole project is one small folder you can paste and run in about ten minutes, then install with npm link so `todo` works from any directory.

  • Setup time: about 5 minutes
  • Runtime dependency: 1 (Commander.js)
  • Commands: add, list, done
  • Stack: TypeScript, Commander.js, tsx, Node's fs module
  • Storage: a local todos.json file, no database

What you will build

By the end of this tutorial you will have a working command-line app called todo with three subcommands: add <task> to add an item, list to print everything, and done <id> to check one off. Data persists between runs in a todos.json file in the project folder, so your list is still there when you come back tomorrow.

Every item is a typed TodoItem, so the code that creates a task and the code that prints it agree on the exact fields, and TypeScript flags any place you drift. Commander.js does the argument parsing: it reads the subcommand and its arguments, generates a --help screen for free, and prints a clear error when someone types a command that does not exist. That is the part worth learning, because it is the same pattern behind tools you already use like git and npm.

Every file you need is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone.

Setup

Create a fresh project and install the tooling. Commander.js is the only runtime dependency; TypeScript, tsx, and @types/node are dev tooling for building and running the .ts files. Commander ships its own type definitions, so there is no separate @types/commander to install.

bash
mkdir todo-cli
cd todo-cli
npm init -y && npm install commander && npm install -D typescript tsx @types/node

Add a tsconfig.json at the project root. These settings run cleanly with tsx in development and compile with tsc for a production build:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"]
}

The "types": ["node"] line pulls in the Node.js type definitions from @types/node, so process, node:fs, and the other Node globals type-check when you build. Then open package.json, add "type": "module", and set these three scripts. dev runs the app directly with tsx (no build step while you work), build compiles to dist/, and start runs the compiled output:

json
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

The three source files below all go in a src/ directory you create in the project root.

Define the data type and storage helpers

Start with the shape of the data. One TodoItem interface, shared everywhere, keeps the app consistent: id to reference a task, task for the text, and done for whether it is finished.

Create src/types.ts:

typescript
export interface TodoItem {
  id: number;
  task: string;
  done: boolean;
}

Now the storage layer. Two functions read and write the list as JSON on disk. The one detail that trips people up is the first run: the todos.json file does not exist yet, so readFileSync throws an ENOENT error. Catch that specific case and return an empty list; rethrow anything else so a real problem (a corrupt file, a permissions error) does not fail silently.

Create src/store.ts:

typescript
import { readFileSync, writeFileSync } from "node:fs";
import type { TodoItem } from "./types.js";

const DB_FILE = "todos.json";

export function loadTodos(): TodoItem[] {
  try {
    return JSON.parse(readFileSync(DB_FILE, "utf8")) as TodoItem[];
  } catch (err) {
    // First run: the file does not exist yet, so start with an empty list.
    if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
    throw err;
  }
}

export function saveTodos(todos: TodoItem[]): void {
  writeFileSync(DB_FILE, JSON.stringify(todos, null, 2));
}

Writing with JSON.stringify(todos, null, 2) keeps the file indented and readable, so you can open todos.json and see exactly what the app stored. The import ... from "./store.js" uses a .js suffix even though the file is store.ts: that is the ES module convention, and it is what lets the same import work under tsx in development and plain Node after you build.

Wire up Commander.js

Now the part that makes it a real CLI. Import Command from commander, create one program, and register a subcommand for each action. Each .command("add <task>") declares its name and its required argument in one string; Commander parses the input, hands your .action() callback the argument, and rejects anything malformed with a helpful message. The angle brackets mean the argument is required, so todo add with no text prints a usage error instead of adding a blank task.

Create src/index.ts:

typescript
#!/usr/bin/env node
import { Command } from "commander";
import { loadTodos, saveTodos } from "./store.js";
import type { TodoItem } from "./types.js";

const program = new Command();

program
  .name("todo")
  .description("A tiny persistent todo list for your terminal")
  .version("1.0.0");

program
  .command("add <task>")
  .description("Add a new task")
  .action((task: string) => {
    const todos = loadTodos();
    const id = todos.reduce((max, t) => Math.max(max, t.id), 0) + 1;
    const item: TodoItem = { id, task, done: false };
    todos.push(item);
    saveTodos(todos);
    console.log(`Added #${id}: ${task}`);
  });

program
  .command("list")
  .description("List all tasks")
  .action(() => {
    const todos = loadTodos();
    if (todos.length === 0) {
      console.log('No tasks yet. Add one with: todo add "your task"');
      return;
    }
    for (const t of todos) {
      const mark = t.done ? "[x]" : "[ ]";
      console.log(`${mark} #${t.id} ${t.task}`);
    }
  });

program
  .command("done <id>")
  .description("Mark a task as done")
  .action((id: string) => {
    const todos = loadTodos();
    const item = todos.find((t) => t.id === Number(id));
    if (!item) {
      console.error(`No task with id ${id}.`);
      process.exitCode = 1;
      return;
    }
    item.done = true;
    saveTodos(todos);
    console.log(`Marked #${item.id} done: ${item.task}`);
  });

program.parse();

Two things worth calling out. The id is Math.max(...existing ids) + 1, not todos.length + 1: reusing the length would hand out a duplicate id after you delete an item, so the max approach stays correct as the list changes. And Commander passes every argument as a string, so done <id> gives you id as text; Number(id) converts it before the lookup. When no id matches, the command sets a non-zero exit code, which is what a shell script wrapping your tool checks to know it failed.

Shore up your TypeScript first

New to TypeScript interfaces and the import/export syntax used here? Scrimba's free TypeScript course is interactive and quick, and Full Stack Open's TypeScript part goes deeper on typing real projects. Both are on our free TypeScript courses page.

Run and test it

While you develop, run the app straight from the TypeScript source with tsx. Add a couple of tasks, list them, then check one off:

bash
npx tsx src/index.ts add "Learn TypeScript"
# -> Added #1: Learn TypeScript

npx tsx src/index.ts add "Build a CLI"
# -> Added #2: Build a CLI

npx tsx src/index.ts list
# -> [ ] #1 Learn TypeScript
# -> [ ] #2 Build a CLI

npx tsx src/index.ts done 1
# -> Marked #1 done: Learn TypeScript

npx tsx src/index.ts list
# -> [x] #1 Learn TypeScript
# -> [ ] #2 Build a CLI

Open todos.json in the project folder and you will see the same data on disk, which is why the list survives between runs. Commander also gave you a help screen for free: run npx tsx src/index.ts --help and it lists every subcommand and its description.

Build for production

To install the tool so you can type todo from any directory, compile it and point package.json at the output. Run the build first:

bash
npm run build
# compiles src/*.ts to dist/*.js

Then add a bin field to package.json mapping the command name to the compiled entry file. The #!/usr/bin/env node shebang at the top of src/index.ts (TypeScript keeps it in the output) is what lets the operating system run the file directly:

json
{
  "type": "module",
  "bin": {
    "todo": "dist/index.js"
  },
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Now run npm link once to symlink the command onto your PATH, and call it like any installed tool:

bash
npm link
# links the "todo" command globally

todo add "Ship the tutorial"
# -> Added #1: Ship the tutorial

todo list
# -> [ ] #1 Ship the tutorial

One thing to know: todo reads and writes todos.json in whatever directory you run it from, so your list is per-folder. That is often what you want for a project-scoped todo list. If you would rather have one global list, change DB_FILE in src/store.ts to an absolute path under your home directory.

Next steps

You have a working, typed CLI with real persistence. A few small additions turn it into something you would actually use.

Add a `delete <id>` command. It is almost identical to done: find the item, then splice it out of the array and save. Add a `--all` flag to `done` so todo done --all clears the whole list in one call; Commander reads flags with .option("--all"). Swap the JSON file for SQLite with better-sqlite3 once the list gets long enough that rewriting the whole file every save feels wasteful; the loadTodos/saveTodos split means only src/store.ts changes.

For deeper TypeScript study, our free TypeScript courses guide ranks the best free options, the TypeScript cheat sheet is a handy reference while you build, and Build a CLI tool for free lists more free courses on this exact topic.

Frequently asked questions

Why Commander.js instead of parsing process.argv myself?

You can read process.argv by hand for one flag, but it gets messy fast once you have subcommands, required arguments, and a help screen. Commander is the most widely used CLI framework for Node, so the pattern transfers to real tools, and it generates --help and clear error messages for you. It is the same reason you would use Express for an HTTP server instead of http.createServer.

Do I need @types/commander?

No. Modern Commander ships its own TypeScript type definitions, so importing Command is fully typed with no extra package. The old @types/commander stub is deprecated and only redirects to Commander's built-in types, so installing it does nothing useful.

Where does the data actually get stored?

In a plain todos.json file that the app writes with Node's fs module. It lives in whatever directory you run the command from. Open it in any editor and you will see your tasks as formatted JSON, which makes the whole thing easy to inspect and debug.

Why run the source with tsx instead of compiling every time?

tsx runs a TypeScript file directly with no separate build step, so the edit-and-run loop is instant while you develop. You still compile with tsc for the production build (the dist/ output that npm link installs). tsx starts faster than ts-node and needs no extra config for ESNext modules.

How do I add a command that takes a flag, like a priority?

Chain .option() onto the command: program.command("add <task>").option("-p, --priority <level>", "task priority"). Commander passes the parsed options as the last argument to your .action callback, so you read options.priority alongside the task argument. Flags with <value> require a value; bare flags like --all are just true or false.

Can I publish this to npm so others can install it?

Yes. Once the bin field points at dist/index.js and the build runs in a prepublish step, npm publish makes it installable with npm install -g your-package-name. Pick a unique package name, keep the shebang line, and make sure the dist/ folder is included in the published files.

Keep going on FreeCodingCourses