Skip to content
aviral gupta

// I3.5 · ~45 min · Intermediate

Build: a to-do tool for the command line

In this build you write a to-do tool you run as node main.js add Buy milk: three modules, a JSON file that keeps the list, and clear errors.

Lesson 5 of 5 in I3 Modules, Node.js and npm

End of the module

You will be able to

  • Read commands and options from the command line with parseArgs from node:util
  • Keep data between runs in a JSON file, starting with an empty list when the file is missing
  • Report wrong input on stderr with an exit code other than 0, and keep the logic in a module you can test
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I3.1. What does node main.js print?

    // todos.js
    export function count(todos) {
      return todos.length;
    }
    
    // main.js
    import {count} from "./todos.js";
    console.log(count(["a", "b"]));
  2. Predict · Activity 2 of 7

    Predict before you read on. You type node main.js add "Buy milk" in the shell. What does main.js print?

    // main.js
    console.log(process.argv.slice(2));
  3. Practice · Activity 3 of 7

    --file takes a value, such as work.json. Fill in its type.

    options: {file: {type: "____", default: "todos.json"}},
    type: "", default
  4. Practice · Activity 4 of 7

    Match each job to the module of the tool that does it.

  5. Practice · Activity 5 of 7

    The list is saved as JSON and read back. What does node main.js print?

    // main.js
    const saved = JSON.stringify([{id: 1, text: "Tea", done: false, added: new Date(0)}]);
    const [todo] = JSON.parse(saved);
    console.log(typeof todo.id, typeof todo.done, typeof todo.added);
  6. Brain teaser · Activity 6 of 7

    Brain teaser. What appears on stdout, and with which exit code does the program end?

    // main.js
    console.error("No to-do with id 7");
    process.exitCode = 1;
    console.log("bye");
  7. Apply · Activity 7 of 7

    Mini-task: put the tool into a folder with package.json ("type": "module") and add two scripts, "todo": "node main.js" and "test": "node --test". Write todos.test.js with two tests for todos.js: ids keep counting after a remove, and an unknown id throws. Run npm test, then npm run todo -- add Buy milk and npm run todo -- list.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The two inner modules at work

Before the command line, here are todos.js and store.js used from a short script. It loads a list from a file that does not exist yet, adds two to-dos, marks one as done, saves the list and reads it back as a later run would. In the exercises you build main.js, the command line around them.

main.js

import {addTodo, markDone, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const file = "example.json";
let todos = await loadTodos(file); // [] the first time: no file yet
console.log(formatList(todos));

todos = addTodo(todos, "Buy milk");
todos = addTodo(todos, "Call Ada");
todos = markDone(todos, 1);
await saveTodos(file, todos);

// A later run would start here: read the file again.
console.log(formatList(await loadTodos(file)));

try {
  markDone(todos, 7);
} catch (error) {
  console.log(error.message);
}

todos.js

// A to-do is {id, text, done}. Every function returns a new array.
export function addTodo(todos, text) {
  const id = todos.length === 0 ? 1 : Math.max(...todos.map((todo) => todo.id)) + 1;
  return [...todos, {id, text, done: false}];
}

export function markDone(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.map((todo) => (todo.id === id ? {...todo, done: true} : todo));
}

export function removeTodo(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.filter((todo) => todo.id !== id);
}

export function formatList(todos) {
  if (todos.length === 0) return "Nothing to do.";
  return todos.map((todo) => `${todo.done ? "[x]" : "[ ]"} ${todo.id}. ${todo.text}`).join("\n");
}

store.js

import {readFile, writeFile} from "node:fs/promises";

// The list in the file, or an empty list if the file does not exist yet.
export async function loadTodos(file) {
  try {
    return JSON.parse(await readFile(file, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return [];
    throw error;
  }
}

export async function saveTodos(file, todos) {
  await writeFile(file, JSON.stringify(todos, null, 2) + "\n");
}

Run it with

node main.js

Output

Nothing to do.
[x] 1. Buy milk
[ ] 2. Call Ada
No to-do with id 7
  • addTodo and markDone return new arrays; the old list is never changed.
  • example.json now holds the list as indented JSON, so you can read it in an editor.
  • markDone throws for an unknown id; main.js turns that into a message on stderr and exit code 1.

Exercises

Exercise 1 of 3

Add with every word

main.js is the command line of the tool, with todos.js and store.js next to it. node main.js add Buy milk should add the to-do "Buy milk", but the starter keeps only the first word. Fix the line that builds the text. The tests use their own --file, so they start with an empty list.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    words holds every word after add. Which of them does the starter use?

  2. Hint 2

    Join the words with a space.

  3. Hint 3

    addTodo(todos, words.join(" "))

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {parseArgs} from "node:util";
import {addTodo, markDone, removeTodo, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const USAGE = "Usage: node main.js [--file todos.json] add <text> | list | done <id> | remove <id>";

const {values, positionals} = parseArgs({
  options: {file: {type: "string", default: "todos.json"}},
  allowPositionals: true
});
const [command, ...words] = positionals;
const todos = await loadTodos(values.file);

try {
  if (command === "add" && words.length > 0) {
    const next = addTodo(todos, words.join(" "));
    await saveTodos(values.file, next);
    console.log(`Added ${next.at(-1).id}. ${next.at(-1).text}`);
  } else if (command === "list") {
    console.log(formatList(todos));
  } else if (command === "done" || command === "remove") {
    const id = Number(words[0]);
    const next = command === "done" ? markDone(todos, id) : removeTodo(todos, id);
    await saveTodos(values.file, next);
    console.log(`${command === "done" ? "Done" : "Removed"}: ${id}`);
  } else {
    console.error(USAGE);
    process.exitCode = 1;
  }
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {parseArgs} from "node:util";
import {addTodo, markDone, removeTodo, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const USAGE = "Usage: node main.js [--file todos.json] add <text> | list | done <id> | remove <id>";

const {values, positionals} = parseArgs({
  options: {file: {type: "string", default: "todos.json"}},
  allowPositionals: true
});
const [command, ...words] = positionals;
const todos = await loadTodos(values.file);

try {
  if (command === "add" && words.length > 0) {
    const next = addTodo(todos, words[0]);
    await saveTodos(values.file, next);
    console.log(`Added ${next.at(-1).id}. ${next.at(-1).text}`);
  } else if (command === "list") {
    console.log(formatList(todos));
  } else if (command === "done" || command === "remove") {
    const id = Number(words[0]);
    const next = command === "done" ? markDone(todos, id) : removeTodo(todos, id);
    await saveTodos(values.file, next);
    console.log(`${command === "done" ? "Done" : "Removed"}: ${id}`);
  } else {
    console.error(USAGE);
    process.exitCode = 1;
  }
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

const file = 'test-' + Date.now() + '-' + Math.floor(Math.random() * 1e6) + '.json';
const todo = (...args) => runMain({args: ['--file', file, ...args]});

test('add Buy milk adds one to-do with both words', async () => {
  const got = await todo('add', 'Buy', 'milk');
  assert.equal(got, 'Added 1. Buy milk\n', `add printed ${JSON.stringify(got)}`);
});

test('list shows it, not done yet', async () => {
  const got = await todo('list');
  assert.equal(got, '[ ] 1. Buy milk\n', `list printed ${JSON.stringify(got)}`);
});

todos.js

// A to-do is {id, text, done}. Every function returns a new array.
export function addTodo(todos, text) {
  const id = todos.length === 0 ? 1 : Math.max(...todos.map((todo) => todo.id)) + 1;
  return [...todos, {id, text, done: false}];
}

export function markDone(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.map((todo) => (todo.id === id ? {...todo, done: true} : todo));
}

export function removeTodo(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.filter((todo) => todo.id !== id);
}

export function formatList(todos) {
  if (todos.length === 0) return "Nothing to do.";
  return todos.map((todo) => `${todo.done ? "[x]" : "[ ]"} ${todo.id}. ${todo.text}`).join("\n");
}

store.js

import {readFile, writeFile} from "node:fs/promises";

// The list in the file, or an empty list if the file does not exist yet.
export async function loadTodos(file) {
  try {
    return JSON.parse(await readFile(file, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return [];
    throw error;
  }
}

export async function saveTodos(file, todos) {
  await writeFile(file, JSON.stringify(todos, null, 2) + "\n");
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

An id from the command line

node main.js done 1 should mark to-do 1 as done, but the starter answers No to-do with id 1. The id comes from the command line as text, and the ids in the list are numbers. Fix the line that reads the id. An unknown id must still end with an exit code other than 0.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    What is typeof words[0]? And what does markDone compare it with?

  2. Hint 2

    === between the text "1" and the number 1 is false.

  3. Hint 3

    const id = Number(words[0]);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {parseArgs} from "node:util";
import {addTodo, markDone, removeTodo, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const USAGE = "Usage: node main.js [--file todos.json] add <text> | list | done <id> | remove <id>";

const {values, positionals} = parseArgs({
  options: {file: {type: "string", default: "todos.json"}},
  allowPositionals: true
});
const [command, ...words] = positionals;
const todos = await loadTodos(values.file);

try {
  if (command === "add" && words.length > 0) {
    const next = addTodo(todos, words.join(" "));
    await saveTodos(values.file, next);
    console.log(`Added ${next.at(-1).id}. ${next.at(-1).text}`);
  } else if (command === "list") {
    console.log(formatList(todos));
  } else if (command === "done" || command === "remove") {
    const id = Number(words[0]);
    const next = command === "done" ? markDone(todos, id) : removeTodo(todos, id);
    await saveTodos(values.file, next);
    console.log(`${command === "done" ? "Done" : "Removed"}: ${id}`);
  } else {
    console.error(USAGE);
    process.exitCode = 1;
  }
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {parseArgs} from "node:util";
import {addTodo, markDone, removeTodo, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const USAGE = "Usage: node main.js [--file todos.json] add <text> | list | done <id> | remove <id>";

const {values, positionals} = parseArgs({
  options: {file: {type: "string", default: "todos.json"}},
  allowPositionals: true
});
const [command, ...words] = positionals;
const todos = await loadTodos(values.file);

try {
  if (command === "add" && words.length > 0) {
    const next = addTodo(todos, words.join(" "));
    await saveTodos(values.file, next);
    console.log(`Added ${next.at(-1).id}. ${next.at(-1).text}`);
  } else if (command === "list") {
    console.log(formatList(todos));
  } else if (command === "done" || command === "remove") {
    const id = words[0];
    const next = command === "done" ? markDone(todos, id) : removeTodo(todos, id);
    await saveTodos(values.file, next);
    console.log(`${command === "done" ? "Done" : "Removed"}: ${id}`);
  } else {
    console.error(USAGE);
    process.exitCode = 1;
  }
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

const file = 'test-' + Date.now() + '-' + Math.floor(Math.random() * 1e6) + '.json';
const todo = (...args) => runMain({args: ['--file', file, ...args]});

test('done 2 marks the second to-do', async () => {
  await todo('add', 'Tea');
  await todo('add', 'Bread');
  const got = await todo('done', '2');
  assert.equal(got, 'Done: 2\n', `done 2 printed ${JSON.stringify(got)}`);
});

test('list shows only the second one as done', async () => {
  const got = await todo('list');
  assert.equal(got, '[ ] 1. Tea\n[x] 2. Bread\n', `list printed ${JSON.stringify(got)}`);
});

test('remove 1 removes the first one', async () => {
  await todo('remove', '1');
  const got = await todo('list');
  assert.equal(got, '[x] 2. Bread\n', `after remove 1, list printed ${JSON.stringify(got)}`);
});

test('done 9 ends with an exit code other than 0', async () => {
  await assert.rejects(todo('done', '9'), Error, 'done 9 ended with exit code 0');
});

todos.js

// A to-do is {id, text, done}. Every function returns a new array.
export function addTodo(todos, text) {
  const id = todos.length === 0 ? 1 : Math.max(...todos.map((todo) => todo.id)) + 1;
  return [...todos, {id, text, done: false}];
}

export function markDone(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.map((todo) => (todo.id === id ? {...todo, done: true} : todo));
}

export function removeTodo(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.filter((todo) => todo.id !== id);
}

export function formatList(todos) {
  if (todos.length === 0) return "Nothing to do.";
  return todos.map((todo) => `${todo.done ? "[x]" : "[ ]"} ${todo.id}. ${todo.text}`).join("\n");
}

store.js

import {readFile, writeFile} from "node:fs/promises";

// The list in the file, or an empty list if the file does not exist yet.
export async function loadTodos(file) {
  try {
    return JSON.parse(await readFile(file, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return [];
    throw error;
  }
}

export async function saveTodos(file, todos) {
  await writeFile(file, JSON.stringify(todos, null, 2) + "\n");
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

Wrong input fails loudly

With no command, an unknown command, or add without words, the tool should print the usage line on stderr and end with exit code 1, so that a script that calls it notices. The starter prints the usage line with console.log, and the exit code stays 0. Fix the else branch.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Two things: where the usage line goes, and the exit code.

  2. Hint 2

    console.error writes to stderr.

  3. Hint 3

    console.error(USAGE); then process.exitCode = 1;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {parseArgs} from "node:util";
import {addTodo, markDone, removeTodo, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const USAGE = "Usage: node main.js [--file todos.json] add <text> | list | done <id> | remove <id>";

const {values, positionals} = parseArgs({
  options: {file: {type: "string", default: "todos.json"}},
  allowPositionals: true
});
const [command, ...words] = positionals;
const todos = await loadTodos(values.file);

try {
  if (command === "add" && words.length > 0) {
    const next = addTodo(todos, words.join(" "));
    await saveTodos(values.file, next);
    console.log(`Added ${next.at(-1).id}. ${next.at(-1).text}`);
  } else if (command === "list") {
    console.log(formatList(todos));
  } else if (command === "done" || command === "remove") {
    const id = Number(words[0]);
    const next = command === "done" ? markDone(todos, id) : removeTodo(todos, id);
    await saveTodos(values.file, next);
    console.log(`${command === "done" ? "Done" : "Removed"}: ${id}`);
  } else {
    console.error(USAGE);
    process.exitCode = 1;
  }
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {parseArgs} from "node:util";
import {addTodo, markDone, removeTodo, formatList} from "./todos.js";
import {loadTodos, saveTodos} from "./store.js";

const USAGE = "Usage: node main.js [--file todos.json] add <text> | list | done <id> | remove <id>";

const {values, positionals} = parseArgs({
  options: {file: {type: "string", default: "todos.json"}},
  allowPositionals: true
});
const [command, ...words] = positionals;
const todos = await loadTodos(values.file);

try {
  if (command === "add" && words.length > 0) {
    const next = addTodo(todos, words.join(" "));
    await saveTodos(values.file, next);
    console.log(`Added ${next.at(-1).id}. ${next.at(-1).text}`);
  } else if (command === "list") {
    console.log(formatList(todos));
  } else if (command === "done" || command === "remove") {
    const id = Number(words[0]);
    const next = command === "done" ? markDone(todos, id) : removeTodo(todos, id);
    await saveTodos(values.file, next);
    console.log(`${command === "done" ? "Done" : "Removed"}: ${id}`);
  } else {
    console.log(USAGE);
  }
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

const file = 'test-' + Date.now() + '-' + Math.floor(Math.random() * 1e6) + '.json';
const todo = (...args) => runMain({args: ['--file', file, ...args]});

test('no command ends with an exit code other than 0', async () => {
  await assert.rejects(todo(), Error, 'node main.js with no command ended with exit code 0');
});

test('add without words ends with an exit code other than 0', async () => {
  await assert.rejects(todo('add'), Error, 'add without words ended with exit code 0');
});

test('an unknown command ends with an exit code other than 0', async () => {
  await assert.rejects(todo('fly'), Error, 'the command fly ended with exit code 0');
});

test('list still works and prints Nothing to do.', async () => {
  const got = await todo('list');
  assert.equal(got, 'Nothing to do.\n', `list printed ${JSON.stringify(got)}`);
});

todos.js

// A to-do is {id, text, done}. Every function returns a new array.
export function addTodo(todos, text) {
  const id = todos.length === 0 ? 1 : Math.max(...todos.map((todo) => todo.id)) + 1;
  return [...todos, {id, text, done: false}];
}

export function markDone(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.map((todo) => (todo.id === id ? {...todo, done: true} : todo));
}

export function removeTodo(todos, id) {
  if (!todos.some((todo) => todo.id === id)) throw new Error(`No to-do with id ${id}`);
  return todos.filter((todo) => todo.id !== id);
}

export function formatList(todos) {
  if (todos.length === 0) return "Nothing to do.";
  return todos.map((todo) => `${todo.done ? "[x]" : "[ ]"} ${todo.id}. ${todo.text}`).join("\n");
}

store.js

import {readFile, writeFile} from "node:fs/promises";

// The list in the file, or an empty list if the file does not exist yet.
export async function loadTodos(file) {
  try {
    return JSON.parse(await readFile(file, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return [];
    throw error;
  }
}

export async function saveTodos(file, todos) {
  await writeFile(file, JSON.stringify(todos, null, 2) + "\n");
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Reading a file that does not exist yet

import {readFile} from "node:fs/promises";

const todos = JSON.parse(await readFile("todos.json", "utf8"));
console.log(todos.length);

What Node.js prints

Error: ENOENT: no such file or directory, open 'todos.json'

Why, and the fix

On the first run there is no file yet. Catch the error, and return an empty list when error.code is "ENOENT"; throw every other error on, so that broken JSON is not silently replaced by an empty list. store.js’s loadTodos does exactly that.

An empty file is not valid JSON

import {readFile, writeFile} from "node:fs/promises";

await writeFile("todos.json", "");
const todos = JSON.parse(await readFile("todos.json", "utf8"));
console.log(todos.length);

What Node.js prints

SyntaxError: Unexpected end of JSON input

Why, and the fix

JSON.parse needs a whole JSON value; an empty text is not one. Always write the list with JSON.stringify, so the file holds at least []. If people edit the file by hand, catch the SyntaxError and say which file is broken instead of starting with an empty list.

An option parseArgs does not know

import {parseArgs} from "node:util";

const {values} = parseArgs({
  args: ["--fil", "work.json"],
  options: {file: {type: "string"}}
});
console.log(values.file);

What Node.js prints

TypeError [ERR_PARSE_ARGS_UNKNOWN_OPTION]: Unknown option '--fil'

Why, and the fix

parseArgs is strict by default: every option must be declared under options, so a typo fails at once instead of being ignored. Here --fil is a typo for --file. In main.js, catch this error and print the usage line, or set strict: false if you really want to accept unknown options.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Three modules, three jobs

todos.js holds pure functions: they take the list and return a new one, and never touch files or the console, so a test can call them directly. store.js reads and writes the list as a JSON file. main.js reads the command line, calls the other two and prints the result. When something changes, you know which file to open: a new command goes into main.js, a new file format into store.js.

Commands and options

process.argv holds every word of the command line; its first two entries are the path of node and of main.js. parseArgs from node:util sorts the rest: options you declare, such as --file with type "string" and a default, end up in values; with allowPositionals: true the other words end up in positionals, such as ["add", "Buy", "milk"]. An option you did not declare throws a TypeError with the code ERR_PARSE_ARGS_UNKNOWN_OPTION.

A file that may not exist yet

The first run has no todos.json. readFile then rejects with an error whose code is "ENOENT", and loadTodos returns an empty list for exactly that code; any other error, such as broken JSON, is thrown on. saveTodos writes JSON.stringify(todos, null, 2), which is easy to read and to diff. Wrong input goes to console.error, which writes to stderr, and process.exitCode = 1 tells the shell that the command failed, while the program still ends normally.

Sources

Last reviewed October 5, 2026