Skip to content
aviral gupta

// 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

End of the module

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
  1. Warm-up · Activity 1 of 7

    Warm-up from module B1: which statements are true? Pick all that apply.

    Select all that apply.

  2. 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 15
  3. Practice · 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"
  4. Practice · Activity 4 of 7

    Match each command line of the finished tool to what it does.

  5. Practice · Activity 5 of 7

    What is the last line that this command prints?

    node main.js 100 -t 10 -p 3
  6. Brain 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 $?
  7. 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.js

Output

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
  1. Hint 1

    The tip is a percentage: amount * tip / 100.

  2. Hint 2

    Each share comes from the total, not from the amount: total / people.

  3. 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.js

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

node --test
Download learnrun.js

Exercise 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
  1. Hint 1

    Number("abc") is NaN, and every comparison with NaN is false. So !(n > 0) is true for NaN, 0 and -5 alike.

  2. Hint 2

    Number.isInteger(2.5) is false; Number.isInteger(3) is true.

  3. 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.js

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

node --test
Download learnrun.js

Exercise 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
  1. Hint 1

    The default can come from the environment: default: env.TIP_PERCENT ?? '15'. It must be a string, and process.env values are.

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

  3. 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.js

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

node --test
Download learnrun.js

Common 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 function

Why, 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.

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

A pure core and a thin shell

The tool splits a bill: node main.js 60 --people 3 prints the tip, the total and each share. bill.js holds the calculation as pure functions: toNumbers checks the text values and turns them into numbers, splitBill does the arithmetic, format builds the lines. They read no process and print nothing, so a test can call them with any input, even in a browser. main.js has run(args, env), which turns a command line into a value: {out, err, code}. Only the last four lines touch the real process: they pass in process.argv.slice(2) and process.env, write out and err, and set process.exitCode. One call of run() shows everything the command would do.

Every outcome has a stream and a code

parseArgs describes --tip (-t), --people (-p) and --help (-h), and allowPositionals lets the amount through. A result goes to stdout with exit code 0. --help is the output the user asked for, so the usage line goes to stdout, also with 0. When parseArgs throws or the amount is missing, the message and the usage line go to stderr with exit code 2: wrong usage. A value it cannot use, such as abc or 2.5 people, gets one error line on stderr and exit code 1. Node.js itself never exits with 2: the docs note that Bash reserves it for misuse of its builtins, and this tool uses it the same way.

Config, scripts and which setting wins

The "start" script is node --env-file-if-exists=.env main.js, and node --run start -- 60 --people 3 appends everything after -- to it. A .env line TIP_PERCENT=10 changes the default tip, a TIP_PERCENT set in the shell wins over the file, and --tip wins over both, because the environment only fills the default of parseArgs. Without a .env file, --env-file-if-exists prints .env not found. Continuing without it. on stderr, so stdout stays clean. The "dev" script adds --watch and a sample command line, so every save reruns it. One catch, measured with Node.js 24.21.0: node --run reports a failing script as exit code 1, while npm start passes the 2 through.

Sources

Last reviewed September 30, 2026