Warm-up · Activity 1 of 7
// B1.5 · ~45 min · Beginner
Build: a tip calculator on the command line
After this build you can write a command-line tool that reads arguments and config, prints results on stdout, answers mistakes on stderr with a meaningful exit code, and runs from package.json scripts.
Lesson 5 of 5 in B1 Running Node and project basics
You will be able to
- Split a tool into pure functions and a thin entry point that reads process.argv and process.env
- Parse arguments with util.parseArgs and answer mistakes on stderr with exit code 2 or 1
- Start the tool from "start" and "dev" scripts with --env-file-if-exists, and predict which setting wins
Predict · Activity 2 of 7
Predict before you read on. The finished tool reads its default tip from TIP_PERCENT. The shell has TIP_PERCENT=20, and .env says TIP_PERCENT=10. You run the command below. What is the first line it prints?
node --env-file=.env main.js 60 --tip 15Practice · Activity 3 of 7
The project has no .env file yet, and the "start" script must work with and without one. Fill in the flag.
{ "type": "module", "scripts": { "start": "node ____=.env main.js" } }"start": "node =.env main.js"Practice · Activity 4 of 7
Match each command line of the finished tool to what it does.
Practice · Activity 5 of 7
What is the last line that this command prints?
node main.js 100 -t 10 -p 3Brain teaser · Activity 6 of 7
Brain teaser. run() returns exit code 2 for an unknown option, and node --env-file-if-exists=.env main.js 60 --colour does end with 2. What does echo $? print after the same command line through the "start" script?
node --run start -- 60 --colour echo $?Apply · Activity 7 of 7
Mini-task. Give the finished tool a currency. In run(), describe --currency (-c), a string whose default is CURRENCY from the environment, or EUR. Print it after every amount: tip: 9.00 EUR. Put CURRENCY=USD in .env and run node --run start -- 60 -p 3, then try -c GBP. Last, start node --run dev, change the currency default and save: the sample command runs again.
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 same shape, a different tool: a word counter
Before you build the tip calculator, here is a finished tool with the same shape. node main.js the cat sat counts the words that have at least --min letters, and MIN_LETTERS from the environment sets the default. run(args, env) returns {out, err, code} instead of printing. A real entry point would end with four lines that pass in process.argv.slice(2) and process.env and act on the result; this demo calls run() with four command lines instead, so you see every outcome at once.
main.js
import {parseArgs} from 'node:util';
const USAGE = 'Usage: node main.js [--min <letters>] <text...>';
// Counts the words of the text that have at least --min letters. Returns what
// to print on stdout and on stderr, and the exit code:
// 0 = done, 1 = a value it cannot use, 2 = wrong usage.
function run(args, env) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {min: {type: 'string', short: 'm', default: env.MIN_LETTERS ?? '1'}},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
if (positionals.length === 0) return {out: '', err: USAGE + '\n', code: 2};
const min = Number(values.min);
if (!Number.isInteger(min) || min < 1) {
return {out: '', err: 'error: --min must be a whole number from 1, got "' + values.min + '"\n', code: 1};
}
const words = positionals.join(' ').split(' ').filter((word) => word.length >= min);
return {out: words.length + '\n', err: '', code: 0};
}
// A real entry point ends with four lines that touch the process:
// const {out, err, code} = run(process.argv.slice(2), process.env);
// process.stdout.write(out);
// process.stderr.write(err);
// process.exitCode = code;
// This demo calls run() with four command lines instead, and MIN_LETTERS=3
// in place of process.env, as if a .env file had set it.
const env = {MIN_LETTERS: '3'};
for (const args of [['the cat sat on a mat'], ['--min', '1', 'the cat sat on a mat'], ['-m', 'two', 'cat'], []]) {
const {out, err, code} = run(args, env);
console.log('args', args);
console.log(' stdout ' + JSON.stringify(out) + ', stderr ' + JSON.stringify(err) + ', exit code ' + code);
}
Run it with
node main.jsOutput
args [ 'the cat sat on a mat' ]
stdout "4\n", stderr "", exit code 0
args [ '--min', '1', 'the cat sat on a mat' ]
stdout "6\n", stderr "", exit code 0
args [ '-m', 'two', 'cat' ]
stdout "", stderr "error: --min must be a whole number from 1, got \"two\"\n", exit code 1
args []
stdout "", stderr "Usage: node main.js [--min <letters>] <text...>\n", exit code 2- With MIN_LETTERS=3 as the default, only the, cat, sat and mat count: 4. --min 1 on the command line wins, and all 6 words count.
- -m two has the right shape but a value it cannot use: one error line and exit code 1. No text at all is wrong usage: the usage line and 2.
- stdout is empty whenever something went wrong, so node main.js … > count.txt never saves a message as if it were a count.
- The parentheses in ({values, positionals} = parseArgs(…)) are needed: a line that starts with { would be read as a block.
Exercises
Exercise 1 of 3
Step 1: split the bill
Write splitBill({amount, tip, people}), where all three are numbers and tip is a percentage. Return {tip, total, each}: the tip amount, the amount plus the tip, and the total divided by the number of people, each rounded to cents with Math.round(x * 100) / 100. So 60 with a 15% tip for 3 people is {tip: 9, total: 69, each: 23}. The function uses no process and prints nothing, so it runs in the browser too.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
The tip is a percentage: amount * tip / 100.
Hint 2
Each share comes from the total, not from the amount: total / people.
Hint 3
Round each of the three with a helper: const cents = (x) => Math.round(x * 100) / 100;
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Step 1: the arithmetic. amount, tip (a percentage) and people are numbers.
// Return the tip, the total and each share, rounded to cents.
export function splitBill({amount, tip, people}) {
const cents = (x) => Math.round(x * 100) / 100;
const tipAmount = cents((amount * tip) / 100);
const total = cents(amount + tipAmount);
return {tip: tipAmount, total, each: cents(total / people)};
}
console.log(splitBill({amount: 60, tip: 15, people: 3}));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// Step 1: the arithmetic. amount, tip (a percentage) and people are numbers.
// Return the tip, the total and each share, rounded to cents.
export function splitBill({amount, tip, people}) {
return {tip: 0, total: amount, each: amount};
}
console.log(splitBill({amount: 60, tip: 15, people: 3}));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {splitBill} from './main.js';
test('60 with a 15% tip for 3 people: tip 9, total 69, each 23', () => {
const got = splitBill({amount: 60, tip: 15, people: 3});
assert.deepEqual(got, {tip: 9, total: 69, each: 23}, `splitBill returned ${JSON.stringify(got)}`);
});
test('each share is rounded to cents: 100 at 10% for 3 people is 36.67 each', () => {
const got = splitBill({amount: 100, tip: 10, people: 3});
assert.deepEqual(got, {tip: 10, total: 110, each: 36.67}, `splitBill returned ${JSON.stringify(got)}`);
});
test('the tip is rounded to cents: 19.99 at 15% gives a tip of 3', () => {
const got = splitBill({amount: 19.99, tip: 15, people: 1});
assert.deepEqual(got, {tip: 3, total: 22.99, each: 22.99}, `splitBill returned ${JSON.stringify(got)}`);
});
test('a 0% tip: the total is the amount', () => {
const got = splitBill({amount: 80, tip: 0, people: 4});
assert.deepEqual(got, {tip: 0, total: 80, each: 20}, `splitBill returned ${JSON.stringify(got)}`);
});
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
Step 2: check the values
parseArgs gives strings. Write toNumbers({amount, tip, people}), which converts the three strings to numbers and returns them as {amount, tip, people}. Throw an Error that names the bad value when one cannot be used: the amount must be above 0, the tip 0 or more, and people a whole number from 1. For example, people "2.5" throws an Error whose message contains 2.5. Like splitBill, it runs in the browser too.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
Number("abc") is NaN, and every comparison with NaN is false. So !(n > 0) is true for NaN, 0 and -5 alike.
Hint 2
Number.isInteger(2.5) is false; Number.isInteger(3) is true.
Hint 3
Put the original text into the message: 'people must be a whole number from 1, got "' + people + '"'.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// Step 2: the values from the command line are strings. Convert them, and
// throw an Error naming the bad value if one cannot be used:
// amount above 0, tip from 0, people a whole number from 1.
export function toNumbers({amount, tip, people}) {
const bill = {amount: Number(amount), tip: Number(tip), people: Number(people)};
if (!(bill.amount > 0)) throw new Error('amount must be a number above 0, got "' + amount + '"');
if (!(bill.tip >= 0)) throw new Error('tip must be a number from 0, got "' + tip + '"');
if (!Number.isInteger(bill.people) || bill.people < 1) throw new Error('people must be a whole number from 1, got "' + people + '"');
return bill;
}
console.log(toNumbers({amount: "60", tip: "15", people: "3"}));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// Step 2: the values from the command line are strings. Convert them, and
// throw an Error naming the bad value if one cannot be used:
// amount above 0, tip from 0, people a whole number from 1.
export function toNumbers({amount, tip, people}) {
return {amount: Number(amount), tip: Number(tip), people: Number(people)};
}
console.log(toNumbers({amount: "60", tip: "15", people: "3"}));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {toNumbers} from './main.js';
const ok = {amount: '60', tip: '15', people: '3'};
test('"60", "15" and "3" become the numbers 60, 15 and 3', () => {
const got = toNumbers(ok);
assert.deepEqual(got, {amount: 60, tip: 15, people: 3}, `toNumbers returned ${JSON.stringify(got)}`);
});
test('an amount that is not above 0 throws: abc, 0, -5', () => {
for (const amount of ['abc', '0', '-5']) {
assert.throws(() => toNumbers({...ok, amount}), Error, `toNumbers with amount "${amount}" should throw`);
}
});
test('a tip below 0 or not a number throws: -1, ten', () => {
for (const tip of ['-1', 'ten']) {
assert.throws(() => toNumbers({...ok, tip}), Error, `toNumbers with tip "${tip}" should throw`);
}
});
test('people must be a whole number from 1: 2.5 and 0 throw', () => {
for (const people of ['2.5', '0']) {
assert.throws(() => toNumbers({...ok, people}), Error, `toNumbers with people "${people}" should throw`);
}
});
test('the message names the bad value', () => {
assert.throws(() => toNumbers({...ok, people: '2.5'}), {message: /2\.5/}, 'the message for people "2.5" should contain 2.5');
assert.throws(() => toNumbers({...ok, amount: 'abc'}), {message: /abc/}, 'the message for amount "abc" should contain abc');
});
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
Step 3: the command
bill.js now holds the finished steps 1 and 2 and format. main.js works for node main.js 60 --people 3, but it fails quietly: every message goes to stdout with exit code 0. Fix run(): the default tip comes from env.TIP_PERCENT, or 15; --help (-h) returns the usage line on stdout with 0; a parseArgs error or a missing amount returns its message and the usage on stderr with 2; a bad value returns error: and its message on stderr with 1. Check with node --test.
This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
The default can come from the environment: default: env.TIP_PERCENT ?? '15'. It must be a string, and process.env values are.
Hint 2
Use two try blocks: one around parseArgs (wrong usage, 2) and one around toNumbers (a bad value, 1). Between them, handle values.help and positionals.length !== 1.
Hint 3
Declare let values, positionals; before the first try, and assign with ({values, positionals} = parseArgs({...})); in parentheses.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import {parseArgs} from 'node:util';
import {format, splitBill, toNumbers} from './bill.js';
const USAGE = 'Usage: node main.js <amount> [--tip <percent>] [--people <count>]';
// The whole command as a function of its arguments and environment. It returns
// what to print on stdout and on stderr, and the exit code:
// 0 = done, 1 = a value it cannot use, 2 = wrong usage.
function run(args, env) {
let values, positionals;
try {
({values, positionals} = parseArgs({
args,
options: {
tip: {type: 'string', short: 't', default: env.TIP_PERCENT ?? '15'},
people: {type: 'string', short: 'p', default: '1'},
help: {type: 'boolean', short: 'h'}
},
allowPositionals: true
}));
} catch (error) {
return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
}
if (values.help) return {out: USAGE + '\n', err: '', code: 0};
if (positionals.length !== 1) return {out: '', err: USAGE + '\n', code: 2};
try {
const bill = toNumbers({amount: positionals[0], tip: values.tip, people: values.people});
return {out: format(splitBill(bill)), err: '', code: 0};
} catch (error) {
return {out: '', err: 'error: ' + error.message + '\n', code: 1};
}
}
// The only lines that touch the real process.
const {out, err, code} = run(process.argv.slice(2), process.env);
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
Run it on your computer
Install Node.js 24 LTS 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 {format, splitBill, toNumbers} from './bill.js';
const USAGE = 'Usage: node main.js <amount> [--tip <percent>] [--people <count>]';
// Returns what to print on stdout and on stderr, and the exit code:
// 0 = done, 1 = a value it cannot use, 2 = wrong usage.
// It works for 60 --people 3, but every mistake ends up on stdout with code 0.
function run(args, env) {
try {
const {values, positionals} = parseArgs({
args,
options: {
tip: {type: 'string', short: 't', default: '15'},
people: {type: 'string', short: 'p', default: '1'}
},
allowPositionals: true
});
const bill = toNumbers({amount: positionals[0], tip: values.tip, people: values.people});
return {out: format(splitBill(bill)), err: '', code: 0};
} catch (error) {
return {out: error.message + '\n' + USAGE + '\n', err: '', code: 0};
}
}
// The only lines that touch the real process.
const {out, err, code} = run(process.argv.slice(2), process.env);
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
test('60 --people 3 prints the tip, the total and each share', async () => {
const got = (await runMain({args: ['60', '--people', '3']})).trimEnd();
assert.equal(got, 'tip: 9.00\ntotal: 69.00\neach: 23.00', `the program printed ${JSON.stringify(got)}`);
});
test('short options work: -t 10 -p 3 100', async () => {
const got = (await runMain({args: ['-t', '10', '-p', '3', '100']})).trimEnd();
assert.equal(got, 'tip: 10.00\ntotal: 110.00\neach: 36.67', `the program printed ${JSON.stringify(got)}`);
});
test('TIP_PERCENT sets the default tip, and --tip still wins', async () => {
process.env.TIP_PERCENT = '10';
try {
const byEnv = (await runMain({args: ['60']})).split('\n')[0];
assert.equal(byEnv, 'tip: 6.00', `with TIP_PERCENT=10, the first line was ${JSON.stringify(byEnv)}`);
const byFlag = (await runMain({args: ['60', '--tip', '20']})).split('\n')[0];
assert.equal(byFlag, 'tip: 12.00', `with --tip 20, the first line was ${JSON.stringify(byFlag)}`);
} finally {
delete process.env.TIP_PERCENT;
}
});
test('--help prints the usage line on stdout', async () => {
const got = await runMain({args: ['--help']});
assert.match(got, /^Usage: node main\.js/, `--help printed ${JSON.stringify(got)}`);
});
test('no amount: an exit code other than 0', async () => {
await assert.rejects(runMain(), Error, 'node main.js with no amount ended with exit code 0');
});
test('an unknown option: an exit code other than 0', async () => {
await assert.rejects(runMain({args: ['60', '--colour']}), Error, 'node main.js 60 --colour ended with exit code 0');
});
test('the amount abc: an exit code other than 0', async () => {
await assert.rejects(runMain({args: ['abc']}), Error, 'node main.js abc ended with exit code 0');
});
bill.js
// Pure functions: no process, no console, so they run anywhere.
// Turns the text values from the command line into numbers, or throws.
export function toNumbers({amount, tip, people}) {
const bill = {amount: Number(amount), tip: Number(tip), people: Number(people)};
if (!(bill.amount > 0)) throw new Error('amount must be a number above 0, got "' + amount + '"');
if (!(bill.tip >= 0)) throw new Error('tip must be a number from 0, got "' + tip + '"');
if (!Number.isInteger(bill.people) || bill.people < 1) throw new Error('people must be a whole number from 1, got "' + people + '"');
return bill;
}
// The tip, the total and each person's share, rounded to cents.
export function splitBill({amount, tip, people}) {
const cents = (x) => Math.round(x * 100) / 100;
const tipAmount = cents((amount * tip) / 100);
const total = cents(amount + tipAmount);
return {tip: tipAmount, total, each: cents(total / people)};
}
// The result as the lines the command prints.
export function format({tip, total, each}) {
return 'tip: ' + tip.toFixed(2) + '\ntotal: ' + total.toFixed(2) + '\neach: ' + each.toFixed(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.jsCommon mistakes
A number as the default of a string option
import {parseArgs} from 'node:util';
const {values} = parseArgs({args: ['60'], options: {tip: {type: 'string', short: 't', default: 15}}, allowPositionals: true});
console.log(values.tip);
What Node.js prints
TypeError [ERR_INVALID_ARG_TYPE]: The "options.tip.default" property must be of type string. Received type number (15)Why, and the fix
The default must match the type of the option, and a string option needs a string: default: '15'. parseArgs checks this before it reads any argument. The value you get back is a string either way, so toNumbers still converts it.
Assigning to declared variables without parentheses
import {parseArgs} from 'node:util';
let values, positionals;
{values, positionals} = parseArgs({args: ['60'], options: {}, allowPositionals: true});
console.log(values, positionals);
What Node.js prints
SyntaxError: Unexpected token '='Why, and the fix
A statement that starts with { is read as a block, not as a pattern, so the = after it makes no sense. Wrap the whole assignment in parentheses: ({values, positionals} = parseArgs({...}));. Inside a single try block you can declare and assign at once instead: const {values, positionals} = parseArgs({...});.
Using an option value as a number
import {parseArgs} from 'node:util';
const {values} = parseArgs({args: ['--tip', '12.5'], options: {tip: {type: 'string', short: 't', default: '15'}}});
console.log('tip ' + values.tip.toFixed(1) + '%');
What Node.js prints
TypeError: values.tip.toFixed is not a functionWhy, and the fix
values.tip is the string '12.5', and strings have no toFixed. Convert first, and check the result: toNumbers turns the text into numbers, or throws with a clear message, before anything calculates with it.
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.