process.argv;Intl.NumberFormat;Learn JS Series):As always, we open with the worked solutions to last episode's three exercises. Read them, sure, but type them out and run them too -- the muscle memory is half the point.
Exercise 1 - truthiness predictions:
console.log(Boolean("false")); // true - a non-empty string
console.log(Boolean(0)); // false
console.log(Boolean([])); // true - objects are ALWAYS truthy, even empty ones
console.log(Boolean(" ")); // true - a single space is not the empty string
console.log(Boolean(null)); // false
console.log(Boolean("0")); // true - still a non-empty string
The insight: the two that fool people are [] (truthy, because it is an object, not because it is "empty") and "0"/"false" (truthy, because they are non-empty strings regardless of what they spell). Only the eight falsy values are false, and everything else is true.
Exercise 2 - the == versus === case, and non-transitivity:
console.log("" == 0); // true - "" coerces to number 0, then 0 == 0
console.log("" === 0); // false - different types, no coercion at all
// non-transitive proof:
console.log("0" == 0); // true
console.log("" == 0); // true
console.log("0" == ""); // false <- A==C and B==C, yet A != B
The insight: == converts "" to 0 before comparing, so "" == 0 is true, while === refuses to convert and reports false. And because "0" == 0 and "" == 0 are both true while "0" == "" is false, == is not transitive -- which is exactly the property that makes it unsafe to reason about.
Exercise 3 - comparing array contents:
function isSameContents(a, b) {
if (a.length !== b.length) return false;
return a.every((value, i) => value === b[i]);
}
console.log(isSameContents([1, 2, 3], [1, 2, 3])); // true
console.log(isSameContents([1, 2, 3], [1, 2, 4])); // false
console.log([1, 2, 3] === [1, 2, 3]); // false (two different objects)
The insight: === on arrays checks reference identity (are these the very same object in memory?), so to compare contents you must walk the elements yourself; every with the index parameter does exactly that, position by position.
Right -- enough theory. Everything in Phase 1 has been building toward this moment. We now take input, control flow, functions, objects, and coercion, and we forge them into a single real program you can run from your terminal and actually use.
A tip calculator you run from the command line like this:
node tip.js 84.50 18 4
That reads as: a bill of 84.50, an 18 percent tip, split evenly among 4 people. The program prints the tip amount, the grand total, and the per-person share, all formatted like proper money. Along the way we validate every argument, because a program worth trusting never blindly trusts what the user typed.
This is small, but it is complete. It has the same skeleton as every serious command-line tool you will ever write: read the input, validate it, do the work, present the result, and report failure cleanly when something is wrong. Master that shape here on a tiny example and it scales all the way up.
When you run a Node script, everything you type after the filename lands in an array called process.argv. The first two entries are always fixed: the path to the Node executable, and the path to your script. So the real arguments -- the ones your user actually typed -- start at index 2:
// tip.js
console.log(process.argv);
// running `node tip.js 84.50 18 4` prints something like:
// [
// "/usr/local/bin/node",
// "/home/you/tip.js",
// "84.50",
// "18",
// "4"
// ]
Because those first two entries are boilerplate we never care about, the standard move is to slice them off and keep the rest:
const args = process.argv.slice(2); // drop node + script path, keep the real args
console.log(args); // ["84.50", "18", "4"]
process.argv is a global handed to us by the Node runtime, not by the JavaScript language itself (remember the language-versus-runtime distinction from episode 1 -- this is a runtime power, the same way document is a browser power). And notice one thing that trips up almost everyone the first time: the arguments arrive as strings. Even "84.50" and "4", which look like numbers, are text. If you forget that and do arithmetic on them, + will happily concatenate instead of add, exactly the coercion trap we dissected in episode 13. So converting them, carefully, is job number one.
Never trust raw input -- treat it as hostile until proven otherwise. We convert each argument to a number and check that it actually makes sense, using the explicit-coercion discipline from episode 13 (Number(), not the sloppy implicit kind). Rather than repeat that logic three times, we write it once as a small helper that parses one value and complains clearly when the value is bad:
function parseNumber(value, label) {
const n = Number(value);
if (Number.isNaN(n)) {
throw new Error(`${label} must be a number, got "${value}"`);
}
if (n < 0) {
throw new Error(`${label} cannot be negative, got ${n}`);
}
return n;
}
Three deliberate choices are packed in there. We use Number() (the strict conversion) rather than parseFloat, because Number("12px") is NaN while parseFloat("12px") would silently return 12 -- and we would rather reject garbage than half-read it. We use Number.isNaN (not the old global isNaN) to catch that garbage, because Number.isNaN does no extra coercion of its own and only answers "is this value literally the NaN number?". And we reject negatives, since a negative bill or a negative headcount is nonsense. The label parameter is a small touch that pays off big: it lets one helper produce specific messages like Bill must be a number or People must be a number, so the user knows which argument they fumbled.
Throwing an Error is how we signal "the input was wrong". We are not going to handle it right here; we let it bubble up and catch it once, at the very top of the program, so all our error handling lives in a single place. That is a pattern you will reach for constantly.
Now the actual maths, written as a pure function (episode 9's small-and-focused rule in action -- it takes inputs, returns an output, and touches nothing else in the outside world). It accepts the three numbers and returns an object describing the result. Money is where floating point bites hardest, so we round at the end with a tiny helper:
function round2(n) {
return Math.round(n * 100) / 100; // round to 2 decimals, still a number
}
function calculateTip(bill, tipPercent, people) {
const tip = round2(bill * (tipPercent / 100));
const total = round2(bill + tip);
const perPerson = round2(total / people);
return { tip, total, perPerson };
}
round2 multiplies by 100 to shift the cents up into the integer range, rounds to a whole number of cents, then divides back down -- a standard trick to tame the IEEE 754 drift we studied in episode 6 (the famous 0.1 + 0.2 not being exactly 0.3). For real financial software you would go further and work in integer cents from the start, or use a decimal library, but for a tip calculator rounding at the boundary is perfectly honest.
calculateTip returns a neat object with three named fields, { tip, total, perPerson }, rather than, say, an array of three unlabelled numbers. That is a real design decision: the caller gets to write result.perPerson (self-documenting) instead of result[2] (a guessing game). Returning a small structured object from a function that computes several related values is idiomatic JavaScript, and it reads beautifully when combined with destructuring, as we will see in a moment.
We want money to always show two decimals -- 84.5 should print as 84.50, not 84.5. The toFixed(2) method does that, and remember from episode 6 that it returns a string, not a number, which is exactly what we want for display. A small formatter keeps all the presentation logic in one spot:
function money(n) {
return `${n.toFixed(2)} EUR`;
}
console.log(money(84.5)); // "84.50 EUR"
console.log(money(15.21)); // "15.21 EUR"
console.log(money(1000)); // "1000.00 EUR"
That works, and for a small tool it is completely fine. But JavaScript ships with something far more capable built right in, and it is worth meeting now because you will use it for the rest of your career: Intl.NumberFormat, part of the internationalization API. It knows about currencies, thousands separators, and the conventions of different locales, so you do not have to hand-roll any of it:
// currencyDisplay "code" prints the 3-letter code (EUR/USD) instead of a symbol
const eur = new Intl.NumberFormat("nl-NL", {
style: "currency",
currency: "EUR",
currencyDisplay: "code",
});
console.log(eur.format(84.5)); // "EUR 84,50" (Dutch: comma is the decimal point)
console.log(eur.format(1234.5)); // "EUR 1.234,50" (Dutch: dot is the thousands separator)
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", currencyDisplay: "code" });
console.log(usd.format(1234.5)); // "USD 1,234.50" (US: comma thousands, dot decimal)
Look at what you get for free: the same number renders as EUR 1.234,50 for a Dutch reader and USD 1,234.50 for an American one, with the thousands and decimal separators swapped correctly for each locale. (Drop the currencyDisplay: "code" option and it prints the local currency symbol instead of the three-letter code.) For our little program we will keep the simple money helper, but now you know the professional tool exists. When a real app needs to show prices to real users, reach for Intl.NumberFormat rather than reinventing it -- it has quietly solved a genuinely hard problem.
Now we assemble the whole program. We read the arguments, guard against the wrong number of them, validate each one, guard against the one arithmetic landmine (dividing by zero people), run the calculation, and print a tidy report:
function main() {
const args = process.argv.slice(2);
if (args.length !== 3) {
console.log("Usage: node tip.js ");
console.log("Example: node tip.js 84.50 18 4");
return;
}
const bill = parseNumber(args[0], "Bill");
const tipPercent = parseNumber(args[1], "Tip percent");
const people = parseNumber(args[2], "People");
if (people === 0) {
throw new Error("People cannot be zero -- there is nobody to split between");
}
const { tip, total, perPerson } = calculateTip(bill, tipPercent, people);
console.log(`Bill: ${money(bill)}`);
console.log(`Tip (${tipPercent}%): ${money(tip)}`);
console.log(`Total: ${money(total)}`);
console.log(`Per person: ${money(perPerson)} (split ${people} ways)`);
}
Notice a few things we have earned across Phase 1. The guard clause from episode 7: if the argument count is wrong, we print usage and return early, which keeps the happy path un-indented and easy to read -- no giant else wrapping the whole function. The destructuring from episode 12: const { tip, total, perPerson } = calculateTip(...) unpacks the returned object into three named variables in a single line. And the separate zero check for people: parseNumber already rejects negatives, but zero is non-negative and would sneak through, so we catch it explicitly before it can produce an Infinity from the division. Small edges like that are precisely where real bugs live.
Finally, we call main inside a try/catch. This is the single place where every thrown validation error is turned into a friendly one-liner, instead of Node dumping an ugly multi-line stack trace at your user:
try {
main();
} catch (err) {
console.error(`Error: ${err.message}`);
process.exitCode = 1; // signal failure to the shell
}
Two details matter here. We use console.error (not console.log) so the message goes to the standard error stream rather than standard output -- that is the correct channel for errors, and it keeps error text out of any pipeline that is capturing the program's real output. And we set process.exitCode = 1, which tells the operating system "this run failed". That exit code is invisible when you run the tool by hand, but it is the difference between a toy and a real tool the moment your program is used inside a shell script, a Makefile, or a CI pipeline -- those all check the exit code to decide whether to continue.
Now run it, and try to break it on purpose:
node tip.js 84.50 18 4
# Bill: 84.50 EUR
# Tip (18%): 15.21 EUR
# Total: 99.71 EUR
# Per person: 24.93 EUR (split 4 ways)
node tip.js 84.50 eighteen 4
# Error: Tip percent must be a number, got "eighteen"
node tip.js 50 10 0
# Error: People cannot be zero -- there is nobody to split between
node tip.js 50 10
# Usage: node tip.js
# Example: node tip.js 84.50 18 4
Every one of those failure runs produces a clear, specific, human message rather than a NaN leaking silently into the output or a scary crash. That is the difference between a script and a tool. The validation is not busywork -- it is the feature that makes the program safe to hand to someone else.
Quite some of you came from the Learn Python Series (and a handful from Rust and Go), so a look sideways is illuminating. Reading command-line arguments is one of those universal tasks every language has to solve, and comparing them shows what JavaScript borrowed and where it went its own way.
Python puts the arguments in sys.argv, a list. Crucially, sys.argv[0] is the script name, so the real arguments start at index 1 -- one earlier than JavaScript, because Python does not prepend the interpreter path the way Node prepends the node path:
import sys
args = sys.argv[1:] # skip the script name, keep the rest
if len(args) != 3:
print("Usage: python tip.py ")
sys.exit(1)
bill = float(args[0]) # like Number(), raises ValueError on garbage
tip = round(bill * float(args[1]) / 100, 2)
print(f"Tip: {tip:.2f} EUR")
The shape is almost identical to ours: slice off the boilerplate, convert strings to numbers, compute, print. Python's float("abc") raises a ValueError (comparable to us throwing an Error), and Python has a superb standard-library module, argparse, for anything beyond trivial argument handling -- it parses flags, defaults, and help text for you.
Rust hands you an iterator of arguments via std::env::args(), and just like Node the first item is the program's own path, so you skip it. But because Rust has no exceptions and no silent coercion, parsing a string into a number returns a Result you are forced to handle -- the compiler will not let you ignore the failure case:
use std::env;
fn main() {
let args: Vec<String> = env::args().skip(1).collect(); // skip program name
if args.len() != 3 {
eprintln!("Usage: tip ");
std::process::exit(1);
}
// .parse() returns a Result; expect() turns a bad parse into a clean crash
let bill: f64 = args[0].parse().expect("bill must be a number");
let tip = bill * args[1].parse::<f64>().expect("tip must be a number") / 100.0;
println!("Tip: {:.2} EUR", tip);
}
Where our JavaScript chooses to validate (nothing forces us to), Rust makes ignoring the failure a compile-time impossibility. That is the same trade we saw with equality in episode 13: more ceremony up front, fewer surprises at runtime.
Go exposes the arguments as a plain slice, os.Args, and -- like Python and unlike Node/Rust -- os.Args[0] is the program name, so the real arguments start at index 1:
package main
import (
"fmt"
"os"
"strconv"
)
func main() {
args := os.Args[1:] // skip program name
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "Usage: tip ")
os.Exit(1)
}
bill, err := strconv.ParseFloat(args[0], 64)
if err != nil {
fmt.Fprintln(os.Stderr, "bill must be a number")
os.Exit(1)
}
fmt.Printf("Bill: %.2f EUR\n", bill)
}
Go's strconv.ParseFloat returns two values -- the number and an error -- and the idiomatic if err != nil check is Go's whole error-handling philosophy in one line. Notice the throughline across all four: read args, skip the boilerplate at the front, convert strings to numbers, validate, and exit with a non-zero code on failure. JavaScript's version is the loosest of the bunch (nothing compels the validation), which is exactly why the discipline of writing it yourself matters so much in JS.
Let us take stock of what you just assembled, because it is more than it looks:
process.argv.slice(2), remembering the runtime prepends two boilerplate entries and that every argument arrives as a string.parseNumber helper using strict Number() conversion, Number.isNaN to catch garbage, and thrown Errors carrying specific, labelled messages.{ tip, total, perPerson } object, with round2 to keep floating-point drift out of the money.money formatter, plus a first look at the professional Intl.NumberFormat for real internationalized currency.try/catch that turns any thrown error into a friendly one-liner, writes it to standard error, and sets a failure exit code.And with that, Phase 1 is complete. Step back and see how far you have come: you started not knowing what a variable was, and you can now take input from the outside world, validate it, compute a result, and present it cleanly and safely. Those are genuine, transferable programming skills -- the exact loop that sits at the heart of every program ever written, in any language.
Next episode opens Phase 2, where we go deep on the single feature that gives JavaScript its real character: functions treated as first-class values -- things you can pass around, return, and store, just like any other piece of data. It changes how you think about the whole language. ;-)