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"]));// I3.5 · ~45 min · Intermediate
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
You will be able to
Warm-up · Activity 1 of 7
// todos.js
export function count(todos) {
return todos.length;
}
// main.js
import {count} from "./todos.js";
console.log(count(["a", "b"]));Predict · Activity 2 of 7
// main.js
console.log(process.argv.slice(2));Practice · Activity 3 of 7
options: {file: {type: "____", default: "todos.json"}},Practice · Activity 4 of 7
Practice · Activity 5 of 7
// 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);Brain teaser · Activity 6 of 7
// main.js
console.error("No to-do with id 7");
process.exitCode = 1;
console.log("bye");Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
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.jsOutput
Nothing to do.
[x] 1. Buy milk
[ ] 2. Call Ada
No to-do with id 7Exercise 1 of 3
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.
words holds every word after add. Which of them does the starter use?
Join the words with a space.
addTodo(todos, words.join(" "))
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;
}
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 3
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.
What is typeof words[0]? And what does markDone compare it with?
=== between the text "1" and the number 1 is false.
const id = Number(words[0]);
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;
}
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 3 of 3
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.
Two things: where the usage line goes, and the exit code.
console.error writes to stderr.
console.error(USAGE); then process.exitCode = 1;
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;
}
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsimport {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.
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 inputWhy, 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.
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
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.