1. The Problem
Build a tip calculator: take a bill amount, tip percentage, and number of people — output the tip amount, total bill, and per-person share. Format all amounts as currency using Intl.NumberFormat.
Why this project? Pure arithmetic functions, floating-point rounding with toFixed, and locale-aware currency formatting — fundamentals that appear in every financial calculation.
2. How to Think About This
Three pure functions: calcTip(bill, pct), calcTotal(bill, tip), splitBill(total, people). Pure means no I/O — only input → output. The CLI layer calls them and formats the results. Easy to test, easy to reuse.
3. The Build
export function calcTip(bill, tipPct) {
if (bill < 0) throw new RangeError("Bill cannot be negative");
if (tipPct < 0 || tipPct > 100) throw new RangeError("Tip % must be 0–100");
return +(bill * tipPct / 100).toFixed(2);
}
export function calcTotal(bill, tip) { return +(bill + tip).toFixed(2); }
export function splitBill(total, people) {
if (!Number.isInteger(people) || people < 1)
throw new RangeError("People must be a positive integer");
return +(total / people).toFixed(2);
}
export function formatCurrency(amount, locale = "en-IN", currency = "INR") {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
}
// CLI
const [,, billStr, tipStr = "18", peopleStr = "1"] = process.argv;
if (!billStr) { console.error("Usage: node tip.mjs <bill> [tip%] [people]"); process.exit(1); }
const bill = parseFloat(billStr);
const tipPct = parseFloat(tipStr);
const people = parseInt(peopleStr, 10);
if (isNaN(bill) || bill < 0) { console.error("Invalid bill amount"); process.exit(1); }
const tip = calcTip(bill, tipPct);
const total = calcTotal(bill, tip);
const perPerson = splitBill(total, people);
const fmt = formatCurrency;
console.log(`
Bill: ${fmt(bill)}`);
console.log(`Tip (${tipPct}%): ${fmt(tip)}`);
console.log(`Total: ${fmt(total)}`);
if (people > 1) console.log(`Per person: ${fmt(perPerson)} (÷${people})`);
4. Test & Prove
import { calcTip, calcTotal, splitBill } from "./tip.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
test("18% tip on 100", () => { if(calcTip(100,18)!==18) throw new Error(calcTip(100,18)); });
test("10% tip on 55", () => { if(calcTip(55,10)!==5.5) throw new Error(); });
test("total 100+18=118", () => { if(calcTotal(100,18)!==118) throw new Error(); });
test("split 120 by 3", () => { if(splitBill(120,3)!==40) throw new Error(); });
test("split 100 by 3", () => { if(splitBill(100,3)!==33.33) throw new Error(splitBill(100,3)); });
test("negative bill", () => { try{calcTip(-1,10);throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
test("tip > 100 throws", () => { try{calcTip(100,200);throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
test("0 people throws", () => { try{splitBill(100,0);throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
console.log(`
${p} passed, ${f} failed`);5. The Interface
node tip.mjs 1200 # ₹1,200 bill, 18% tip, 1 person node tip.mjs 1200 20 4 # ₹1,200 bill, 20% tip, split 4 ways Bill: ₹1,200.00 Tip (20%): ₹240.00 Total: ₹1,440.00 Per person: ₹360.00 (÷4)
6. Run It
node tip.mjs 1200 20 4
node tip.test.mjs🎯 Try this next
- Add custom rounding options — always round up to the nearest whole amount
- Add a
--currencyflag:node tip.mjs 50 18 2 --currency USD - Build a browser version with live calculation as the user types
- Add split suggestions — show 15%, 18%, and 20% tip options at once
What you practised
Pure functions for arithmetic, toFixed(2) + unary + to round cleanly, Intl.NumberFormat for locale-aware currency, parseFloat/parseInt from CLI args, and defensive validation with RangeError.
Reference: Numbers & Math · Date & Intl