JavaScript: Basic, 41–80
The on-ramp: numbers, text, dates, conditionals and loops.
Exercises 41 to 80 of 150
41 Count words in a sentence
easyCount the words in a piece of text. Decide what happens with leading spaces, double spaces and an empty string.
Stuck? Three hints, smallest first
- 1.
''.split(' ')gives[''], length 1. An empty string has no words. - 2. Trim first, then split on
/\s+/, then handle the empty case explicitly. - 3. A regex
match(/\S+/g)avoids the empty-string problem entirely, but returnsnull.
function countWords(text) {
const trimmed = text.trim();
if (trimmed === '') return 0; // the case split() gets wrong
return trimmed.split(/\s+/).length;
}
// The regex route. No empty-string special case, but match returns null.
const countWordsRegex = (text) => (text.match(/\S+/g) ?? []).length;
// The naive version, so the failures are visible.
const naive = (text) => text.split(' ').length;
const samples = ['one two three', ' padded ', 'double spaces here', '', 'one'];
for (const s of samples) {
console.log(JSON.stringify(s).padEnd(24), countWords(s));
}
console.log('naive on "":', naive(''), '<- claims one word');
console.log('naive on "a b":', naive('a b'), '<- claims three');
Output
10 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 10 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('three words', countWords('one two three'), 3);
test('padding ignored', countWords(' padded '), 1);
test('double spaces do not add words', countWords('double spaces here'), 3);
test('empty text has no words', countWords(''), 0);
test('whitespace only has no words', countWords(' '), 0);
test('one word', countWords('one'), 1);
test('newlines count as separators', countWords('a\nb\tc'), 3);
test('the naive version miscounts empty text', naive(''), 1);
test('and double spaces', naive('a b'), 3);
test('the regex version agrees everywhere', samples.every((s) => countWords(s) === countWordsRegex(s)), true);
''.split(' ') returning [''] is the trap, and it means the obvious one-liner reports one word for an empty box. Trimming plus an explicit zero case fixes it; so does match(/\S+/g), provided you remember it hands back null rather than an empty array.
42 Find the first non-repeating character
fairGiven a piece of text, find the first character that appears exactly once. Return something sensible when there is no such character.
Stuck? Three hints, smallest first
- 1. Count every character first, then walk the text again and return the first with a count of one.
- 2. Two passes is correct here. One pass cannot know a character is unique until the end.
- 3. A
Mapkeeps insertion order, so you can even skip the second pass over the text.
function firstUnique(text) {
const counts = new Map();
for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1);
// Walk the original order, not the map, so ties resolve by position.
for (const ch of text) if (counts.get(ch) === 1) return ch;
return null;
}
// Because a Map preserves insertion order, this also works.
function firstUniqueViaMap(text) {
const counts = new Map();
for (const ch of text) counts.set(ch, (counts.get(ch) ?? 0) + 1);
for (const [ch, n] of counts) if (n === 1) return ch;
return null;
}
for (const s of ['swiss', 'aabbcc', 'x', 'aabbc', '']) {
console.log(JSON.stringify(s).padEnd(10), '->', JSON.stringify(firstUnique(s)));
}
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('w in swiss', firstUnique('swiss'), 'w');
test('none when all repeat', firstUnique('aabbcc'), null);
test('a single character is unique', firstUnique('x'), 'x');
test('the last one can be the answer', firstUnique('aabbc'), 'c');
test('empty text has none', firstUnique(''), null);
test('case matters', firstUnique('aAbB'), 'a');
test('both methods agree', ['swiss', 'aabbcc', 'x', 'aabbc', ''].every((s) => firstUnique(s) === firstUniqueViaMap(s)), true);
Two passes is not a weakness here: you cannot know whether the first character is unique until you have seen the whole string, so any correct answer reads the input twice. The Map variant is neat because insertion order means the map itself already holds the characters in first-seen order, so the second pass can be over the map rather than the text.
43 Convert a string to title case, respecting small words
fairTitle-case a sentence, but leave short joining words such as "of" and "the" in lower case unless they start the title.
Stuck? Three hints, smallest first
- 1. Keep a set of words that stay lower case: articles, conjunctions and short prepositions.
- 2. The first word is always capitalised, whatever it is.
- 3. Many style guides also capitalise the last word. Decide and say so.
const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for',
'in', 'nor', 'of', 'on', 'or', 'the', 'to', 'up', 'via']);
const cap = (w) => (w.length === 0 ? w : w[0].toUpperCase() + w.slice(1).toLowerCase());
function titleCase(sentence) {
const words = sentence.toLowerCase().split(/\s+/).filter(Boolean);
return words
.map((w, i) => {
// First and last always capitalised; small words in between are not.
const isEdge = i === 0 || i === words.length - 1;
return !isEdge && SMALL.has(w) ? w : cap(w);
})
.join(' ');
}
const samples = [
'the lord of the rings',
'a tale of two cities',
'gone with the wind',
'of mice and men',
];
for (const s of samples) console.log(titleCase(s));
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('small words stay lower', titleCase('the lord of the rings'), 'The Lord of the Rings');
test('the first word is always capitalised', titleCase('of mice and men'), 'Of Mice and Men');
test('and so is the last', titleCase('gone with the wind'), 'Gone With the Wind');
test('a single small word', titleCase('the'), 'The');
test('mixed input is normalised first', titleCase('THE LORD OF THE RINGS'), 'The Lord of the Rings');
test('extra spaces collapse', titleCase('a tale of two cities'), 'A Tale of Two Cities');
test('empty input', titleCase(''), '');
The rule "first and last are always capitalised" is a real convention rather than a convenience, and it is why 'of mice and men' becomes 'Of Mice and Men' with a capital O. Note the input is lowercased first, so a title arriving in shouty caps is normalised rather than left alone — without that, 'THE LORD OF THE RINGS' would come back unchanged.
44 Chunk an array into fixed-size pieces
easySplit a list into groups of a given size, with the last group holding whatever is left over.
Stuck? Three hints, smallest first
- 1.
slice(i, i + size)in a loop stepping bysizeis the whole job. - 2. A size of zero or less would loop forever. Refuse it.
- 3.
Array.from({ length: Math.ceil(n / size) })gives a version with no mutation.
function chunk(values, size) {
if (!Number.isInteger(size) || size < 1) {
throw new RangeError(`chunk size must be a whole number of 1 or more, got ${size}`);
}
const out = [];
for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size));
return out;
}
// The same without a mutable accumulator.
const chunkFrom = (values, size) =>
Array.from({ length: Math.ceil(values.length / size) }, (_, i) =>
values.slice(i * size, i * size + size));
console.log(chunk([1, 2, 3, 4, 5, 6, 7], 3));
console.log(chunk(['a', 'b', 'c'], 1));
let refused = '';
try { chunk([1, 2], 0); } catch (err) { refused = err.message; }
console.log('refused:', refused);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('an uneven split keeps the remainder', chunk([1, 2, 3, 4, 5, 6, 7], 3), [[1, 2, 3], [4, 5, 6], [7]]);
test('an exact split', chunk([1, 2, 3, 4], 2), [[1, 2], [3, 4]]);
test('size one wraps each item', chunk(['a', 'b'], 1), [['a'], ['b']]);
test('a size larger than the list gives one chunk', chunk([1, 2], 9), [[1, 2]]);
test('an empty list gives no chunks', chunk([], 3), []);
test('size zero is refused', refused.includes('1 or more'), true);
test('both versions agree', chunkFrom([1, 2, 3, 4, 5], 2), chunk([1, 2, 3, 4, 5], 2));
test('the original is not modified', (() => { const a = [1, 2, 3]; chunk(a, 2); return a; })(), [1, 2, 3]);
The guard is not padding. With size of zero, i += 0 never advances and the loop runs until the tab dies — a hang rather than an error, which is much harder to diagnose. Refusing it up front turns an infinite loop into a clear message.
45 Sum only the numbers in a mixed list
easyAdd up the numeric values in a list that also contains other things. Be careful about what counts as a number.
Stuck? Three hints, smallest first
- 1.
typeof v === 'number'is the check, but it also acceptsNaNandInfinity. - 2.
Number.isFinite(v)accepts only real, usable numbers. - 3. Should the string
'5'count? Decide and say so, rather than letting coercion decide for you.
const mixed = [1, 'two', 3, null, 4.5, undefined, NaN, Infinity, true, '6'];
// Strict: only real numbers. NaN, Infinity, booleans and numeric strings are out.
const sumStrict = (values) =>
values.filter((v) => typeof v === 'number' && Number.isFinite(v))
.reduce((n, v) => n + v, 0);
// Lenient: also accept strings that are entirely a number.
const sumLenient = (values) =>
values.reduce((n, v) => {
if (typeof v === 'number' && Number.isFinite(v)) return n + v;
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return n + Number(v);
return n;
}, 0);
console.log('strict :', sumStrict(mixed));
console.log('lenient:', sumLenient(mixed));
// Why typeof alone is not enough:
console.log('typeof NaN :', typeof NaN);
console.log('typeof Infinity :', typeof Infinity);
// And why coercion is a bad default: true would become 1.
console.log('Number(true) is', Number(true));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('strict sum', sumStrict(mixed), 8.5);
test('lenient sum includes the numeric string', sumLenient(mixed), 14.5);
test('NaN is typed number but not finite', typeof NaN === 'number' && !Number.isFinite(NaN), true);
test('Infinity is excluded', sumStrict([1, Infinity]), 1);
test('booleans do not sneak in', sumStrict([1, true]), 1);
test('nor do they in the lenient version', sumLenient([1, true]), 1);
test('an empty list sums to zero', sumStrict([]), 0);
test('a list of no numbers sums to zero', sumStrict(['a', null]), 0);
typeof NaN === 'number' is the reason Number.isFinite exists. A single NaN in the list poisons the whole sum to NaN, and nothing warns you. Note also that both versions refuse true: JavaScript would happily coerce it to 1, and a total that silently counts checkboxes as money is worse than one that ignores them.
46 Rotate an array by n places
fairMove every element of a list n places to the left, wrapping around. Handle an n larger than the list and a negative n.
Stuck? Three hints, smallest first
- 1.
n % lengthhandles an n larger than the list. - 2. JavaScript’s
%keeps the sign of the left operand, so-1 % 5is-1, not4. - 3.
((n % len) + len) % lenis the standard fix for a positive remainder.
function rotate(values, n) {
const len = values.length;
if (len === 0) return [];
// JavaScript's % keeps the sign of the left side, so normalise it.
const shift = ((n % len) + len) % len;
return [...values.slice(shift), ...values.slice(0, shift)];
}
const nums = [1, 2, 3, 4, 5];
console.log('by 2 :', rotate(nums, 2));
console.log('by 7 :', rotate(nums, 7), '(same as 2)');
console.log('by -1:', rotate(nums, -1));
// The trap this avoids:
console.log('-1 % 5 in JavaScript is', -1 % 5, '-- not 4');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('rotate left by two', rotate(nums, 2), [3, 4, 5, 1, 2]);
test('a full rotation is the identity', rotate(nums, 5), nums);
test('more than a full rotation wraps', rotate(nums, 7), [3, 4, 5, 1, 2]);
test('negative rotates the other way', rotate(nums, -1), [5, 1, 2, 3, 4]);
test('a large negative also wraps', rotate(nums, -6), [5, 1, 2, 3, 4]);
test('zero changes nothing', rotate(nums, 0), nums);
test('an empty list is fine', rotate([], 3), []);
test('the original is untouched', (() => { const a = [1, 2, 3]; rotate(a, 1); return a; })(), [1, 2, 3]);
test('JavaScript modulo really does go negative', -1 % 5, -1);
-1 % 5 being -1 rather than 4 is the whole exercise. JavaScript's % is a remainder, not a mathematical modulo: it takes the sign of the left operand. ((n % len) + len) % len is the idiom that gives you a genuine modulo, and it is worth memorising because this bites in every wrap-around problem — clock arithmetic, circular buffers, grid wrapping.
47 Check whether two words are anagrams
easyDecide whether two words use exactly the same letters. Ignore case and spaces.
Stuck? Three hints, smallest first
- 1. Normalise both, then compare sorted letters.
- 2. Sorting is the clear answer; counting letters avoids the sort and is faster for long input.
- 3. Different lengths after normalising means no, so check that first and skip the work.
const normalise = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
// The clear version.
function isAnagram(a, b) {
const x = normalise(a);
const y = normalise(b);
if (x.length !== y.length) return false;
return [...x].sort().join('') === [...y].sort().join('');
}
// The counting version: one pass each, no sort.
function isAnagramCounted(a, b) {
const x = normalise(a);
const y = normalise(b);
if (x.length !== y.length) return false;
const counts = new Map();
for (const ch of x) counts.set(ch, (counts.get(ch) ?? 0) + 1);
for (const ch of y) {
const left = counts.get(ch);
if (left === undefined || left === 0) return false;
counts.set(ch, left - 1);
}
return true;
}
const pairs = [
['listen', 'silent'],
['Dormitory', 'Dirty Room'],
['hello', 'world'],
['aab', 'abb'],
];
for (const [a, b] of pairs) {
console.log(a.padEnd(11), b.padEnd(11), isAnagram(a, b));
}
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a simple pair', isAnagram('listen', 'silent'), true);
test('case and spaces ignored', isAnagram('Dormitory', 'Dirty Room'), true);
test('different letters', isAnagram('hello', 'world'), false);
test('same letters, different counts', isAnagram('aab', 'abb'), false);
test('a word is an anagram of itself', isAnagram('lagos', 'lagos'), true);
test('empty strings match', isAnagram('', ''), true);
test('both methods agree', pairs.every(([a, b]) => isAnagram(a, b) === isAnagramCounted(a, b)), true);
'aab' against 'abb' is the case that catches a naive answer: same length, same set of letters, different counts. Comparing new Set contents would wrongly say yes. Sorting handles it because it compares multiplicities, and so does the counting version, which is the one to reach for when the strings are long.
48 Format a number with thousands separators
easyPrint a large number with separators between the thousands. Use the built-in tool and note what it gives you beyond commas.
Stuck? Three hints, smallest first
- 1.
toLocaleString()is the built-in and handles grouping for you. - 2. Pass a locale explicitly, or the output depends on the reader’s machine and cannot be tested.
- 3.
Intl.NumberFormatis the same engine and is faster if you format many numbers.
// Always pass the locale: without it the output depends on the machine,
// so the same code prints 1,234.5 for one reader and 1.234,5 for another.
const grouped = (n) => n.toLocaleString('en-NG');
// Reuse a formatter when doing many numbers: constructing one is the slow part.
const naira = new Intl.NumberFormat('en-NG', {
style: 'currency',
currency: 'NGN',
maximumFractionDigits: 0,
});
console.log(grouped(1234567));
console.log(grouped(1234.5));
console.log('as currency:', naira.format(18000));
// The hand-rolled version, for comparison. Works, but only for plain grouping.
const byRegex = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
console.log('by regex:', byRegex(1234567));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('millions are grouped', grouped(1234567), '1,234,567');
test('a decimal keeps its point', grouped(1234.5), '1,234.5');
test('small numbers are unchanged', grouped(999), '999');
test('zero', grouped(0), '0');
test('the regex version agrees for whole numbers', byRegex(1234567), '1,234,567');
test('but it mangles decimals', byRegex(1234.5), '1,234.5');
test('currency formatting adds the symbol', naira.format(18000).includes('18,000'), true);
test('negatives are handled', grouped(-4500), '-4,500');
Passing the locale is the detail that matters. toLocaleString() with no argument uses the reader's own settings, so the same code produces 1,234.5 in Nigeria and 1.234,5 in Germany — correct behaviour, and untestable. Naming the locale makes the output deterministic. And if you are formatting a list, build one Intl.NumberFormat and reuse it; constructing it is far more expensive than calling it.
49 Validate an email address, honestly
fairCheck whether text looks like an email address. Then explain why any such check is a rough filter rather than validation.
Stuck? Three hints, smallest first
- 1. A fully correct email regex is thousands of characters long and still cannot tell you the address exists.
- 2. A pragmatic check is: something, an @, something, a dot, something.
- 3. The only real validation is sending a message and seeing whether it arrives.
// Deliberately loose. Rejects the obviously broken and accepts the rest.
const looksLikeEmail = (text) =>
typeof text === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text.trim());
const samples = [
'amaka@example.com',
'chidi.eze+bookings@bus.example.co.uk',
'no-at-sign.com',
'two@@example.com',
'spaces in@example.com',
'missing@dot',
'',
];
for (const s of samples) {
console.log(JSON.stringify(s).padEnd(38), looksLikeEmail(s));
}
// What the check cannot tell you:
console.log('');
console.log('accepted, but almost certainly not a real inbox:', looksLikeEmail('a@b.c'));
Output
10 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 10 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('an ordinary address', looksLikeEmail('amaka@example.com'), true);
test('a plus tag and multiple dots', looksLikeEmail('chidi.eze+bookings@bus.example.co.uk'), true);
test('no at sign', looksLikeEmail('no-at-sign.com'), false);
test('two at signs', looksLikeEmail('two@@example.com'), false);
test('spaces', looksLikeEmail('spaces in@example.com'), false);
test('no dot in the domain', looksLikeEmail('missing@dot'), false);
test('empty text', looksLikeEmail(''), false);
test('surrounding spaces are trimmed, not rejected', looksLikeEmail(' amaka@example.com '), true);
test('a non-string is refused rather than crashing', looksLikeEmail(null), false);
test('it accepts things that are not real inboxes', looksLikeEmail('a@b.c'), true);
The last assertion is deliberate and is the lesson. a@b.c passes every regex you might write and is not a real address. Fully validating email syntax needs a monster of a pattern that still tells you nothing about deliverability, so the useful design is a loose filter to catch typos, then a confirmation message to do the actual validating. Note the typeof guard too: without it, looksLikeEmail(null) throws instead of returning false.
50 Retry a flaky operation with backoff
hardCall something that sometimes fails, retrying a few times with a growing wait between attempts, then giving up. Adapted: uses an injected clock so the waits can be asserted rather than waited out.
Stuck? Three hints, smallest first
- 1. Each wait should be longer than the last, usually doubling. That is exponential backoff.
- 2. Give up after a set number of attempts and rethrow the last failure.
- 3. Real code adds a random jitter so a thousand clients do not all retry in the same instant.
function makeClock() {
let now = 0;
const jobs = [];
return {
wait(ms) {
// A promise that resolves when the clock is advanced far enough.
return new Promise((resolve) => jobs.push({ at: now + ms, resolve }));
},
async advance(ms) {
// Drain first, THEN move the clock. Code that is mid-attempt has not
// registered its wait yet, and a wait registered after the clock moved
// would be timed from the wrong "now" and never come due. Letting it
// settle first is what keeps each wait on the schedule it asked for.
for (let turn = 0; turn < 20; turn++) await Promise.resolve();
now += ms;
for (const j of jobs.filter((j) => j.at <= now)) {
jobs.splice(jobs.indexOf(j), 1);
j.resolve();
}
for (let turn = 0; turn < 20; turn++) await Promise.resolve();
},
get time() { return now; },
};
}
async function retry(fn, { attempts = 4, baseMs = 100, clock }) {
const waits = [];
let lastError;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return { value: await fn(attempt), attempt, waits };
} catch (err) {
lastError = err;
if (attempt === attempts) break;
const wait = baseMs * 2 ** (attempt - 1); // 100, 200, 400, ...
waits.push(wait);
await clock.wait(wait);
}
}
throw lastError;
}
const clock = makeClock();
// Fails twice, then works.
let calls = 0;
const flaky = async () => {
calls++;
if (calls < 3) throw new Error('temporarily unavailable');
return 'payload';
};
const running = retry(flaky, { baseMs: 100, clock });
await clock.advance(100); // let the first backoff elapse
await clock.advance(200); // and the second
const result = await running;
console.log('succeeded on attempt', result.attempt);
console.log('waits used:', result.waits);
console.log('total simulated time:', clock.time, 'ms');
Output
6 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 6 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('it eventually succeeded', result.value, 'payload');
test('on the third attempt', result.attempt, 3);
test('after two waits', result.waits.length, 2);
test('and they doubled', result.waits, [100, 200]);
test('the function was called three times', calls, 3);
test('no real time was spent', clock.time, 300);
Adapted from the original, which would use real timers and therefore take a real second to test. Injecting the clock means the doubling is asserted exactly: [100, 200], three calls, 300ms of simulated time and none of real. In production add a random jitter to each wait — without it, every client that failed at the same moment retries at the same moment, which is how a brief outage becomes a stampede.
51 Round to a given number of decimal places
easyRound a number to two decimal places, then to any number of places. Say what type toFixed hands back.
Stuck? Three hints, smallest first
- 1.
toFixed(2)rounds to two places, but read its return type carefully. - 2. To round and keep a number, multiply by a power of ten,
Math.round, then divide back. - 3. Try
(1.005).toFixed(2)and see whether you get what you expected.
// toFixed rounds to a fixed width and returns TEXT, not a number.
const money = (n) => n.toFixed(2);
// Rounding and staying numeric: scale up, round, scale back down.
const roundTo = (n, places) => {
const factor = 10 ** places;
return Math.round(n * factor) / factor;
};
console.log('toFixed(2) ->', money(3.14159), 'type', typeof money(3.14159));
console.log('roundTo(,2) ->', roundTo(3.14159, 2), 'type', typeof roundTo(3.14159, 2));
console.log('padded ->', (2.5).toFixed(3));
console.log('1.005 to 2 places ->', (1.005).toFixed(2), '<- not 1.01');
console.log('because it is stored as ', (1.005).toFixed(17));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('toFixed hands back a string', typeof (3.14159).toFixed(2), 'string');
test('so it needs converting back', roundTo(3.14159, 2), 3.14);
test('three places', roundTo(3.14159, 3), 3.142);
test('toFixed pads out to the width asked for', (2.5).toFixed(3), '2.500');
test('1.005 rounds DOWN, which looks wrong', (1.005).toFixed(2), '1.00');
test('and so does 2.675', (2.675).toFixed(2), '2.67');
test('the stored value explains it', (1.005).toFixed(17), '1.00499999999999989');
test('a string will not add up', (1.5).toFixed(2) + 1, '1.501');
Two separate lessons. First, toFixed returns a string, so price.toFixed(2) + 1 gives "1.501" rather than 2.5 — the last assertion is the bug as it actually appears, in a total that comes out as gibberish. Wrap it in Number(...) the moment you need arithmetic again.
Second, (1.005).toFixed(2) is "1.00", and that is not a bug in toFixed. The literal 1.005 cannot be stored exactly, so what is actually in the variable is 1.00499999999999989, and rounding that to two places is correctly 1.00. Exercise 63 is the fix for money: keep integers.
52 floor, ceil, round and trunc
easyApply all four rounding functions to 4.7 and to -4.7, and work out which pairs agree and which do not.
Stuck? Three hints, smallest first
- 1.
floorgoes down the number line,ceilgoes up. Neither cares about zero. - 2.
truncdrops the fraction, so it moves TOWARDS zero, which is downward for a positive and upward for a negative. - 3.
Math.roundbreaks a tie upwards, which for-2.5means-2.
const ways = (n) => ({
floor: Math.floor(n), // down, always
ceil: Math.ceil(n), // up, always
round: Math.round(n), // to the nearest, ties go up
trunc: Math.trunc(n), // fraction dropped, so towards zero
});
console.log('4.7 ->', ways(4.7));
console.log('-4.7 ->', ways(-4.7), '<- floor and trunc disagree');
console.log('ties: 2.5 ->', Math.round(2.5), ' -2.5 ->', Math.round(-2.5));
console.log('Math.round(-0.5) is', Math.round(-0.5), 'and Object.is(-0, 0) is', Object.is(Math.round(-0.5), 0));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a positive number', ways(4.7), { floor: 4, ceil: 5, round: 5, trunc: 4 });
test('a negative number is where they part', ways(-4.7), { floor: -5, ceil: -4, round: -5, trunc: -4 });
test('a tie rounds up', Math.round(2.5), 3);
test('so a negative tie rounds towards zero', Math.round(-2.5), -2);
test('Math.round(-0.5) is minus zero', Object.is(Math.round(-0.5), -0), true);
test('which JSON cannot tell from zero', JSON.stringify(Math.round(-0.5)), '0');
test('floor and trunc are the same for positives', Math.floor(4.7) === Math.trunc(4.7), true);
test('and different for negatives', Math.floor(-0.9) === Math.trunc(-0.9), false);
test('the bitwise trick truncates, but only within 32 bits', (2 ** 31) | 0, -2147483648);
The whole difference lives in the negatives. Math.floor(-4.7) is -5 and Math.trunc(-4.7) is -4, so code that used floor to "remove the decimals" quietly changes answer as soon as a negative arrives — a refund, a temperature, a debit.
Math.round(-2.5) being -2 surprises people who expect symmetry: the rule is "ties go up the number line", not "ties go away from zero". And x | 0 is a fast truncate that silently wraps past 2³¹, which is why the last assertion answers a large negative rather than the two billion you passed in.
53 Absolute value and sign
easyGet the size of a number regardless of direction, and separately get its direction. Use them together to find the distance between two account balances.
Stuck? Three hints, smallest first
- 1.
Math.absdrops the sign;Math.signkeeps only the sign. - 2.
Math.signanswers-1,0or1— and for-0it answers-0. - 3. The distance between two numbers is
Math.abs(a - b), whichever way round they are.
const distance = (a, b) => Math.abs(a - b);
// Money in kobo, so the arithmetic is exact.
const balanceA = -450_00; // overdrawn by 450 naira
const balanceB = 1_200_00;
console.log('abs(-7) ->', Math.abs(-7));
console.log('sign(-3) ->', Math.sign(-3), 'sign(3) ->', Math.sign(3), 'sign(0) ->', Math.sign(0));
console.log('gap in kobo ->', distance(balanceA, balanceB));
console.log('abs of text ->', Math.abs('-7'), '<- coerced, quietly');
console.log('abs of rubbish ->', Math.abs('lagos'));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('size without direction', Math.abs(-7), 7);
test('a positive is untouched', Math.abs(7), 7);
test('the distance is the same either way round', distance(balanceA, balanceB) === distance(balanceB, balanceA), true);
test('and it is 1650 naira', distance(balanceA, balanceB), 165000);
test('sign reduces to three answers', [Math.sign(-3), Math.sign(0), Math.sign(3)], [-1, 0, 1]);
test('but minus zero keeps its sign', Object.is(Math.sign(-0), -0), true);
test('text is coerced rather than refused', Math.abs('-7'), 7);
test('and nonsense gives NaN', Number.isNaN(Math.abs('lagos')), true);
Math.abs coerces before it computes, so Math.abs('-7') is 7 and Math.abs('lagos') is NaN. That NaN then spreads through every sum it touches without throwing, which is why the check is Number.isNaN(...) against true and not test('...', x, NaN) — the latter would pass even if x were the wrong value entirely, since JSON.stringify(NaN) is "null".
Math.sign(-0) being -0 is a curiosity until you sort by it, at which point a "zero" row lands on the negative side. Note the 450_00 digit separator: it reads as naira-and-kobo while staying one integer.
54 Smallest and largest in a list
easyFind the smallest and largest number in an array. Also exercise 18, there picking the largest of three named values; here the list can be any length, which changes the tool.
Stuck? Three hints, smallest first
- 1.
Math.maxtakes separate arguments, not an array. Spread the array into it. - 2. Spreading has a limit — around 100,000 arguments, depending on the engine — so a reduce is safer for very long lists.
- 3.
Math.min()with no arguments has to answer something. Work out what, and why that answer is the right one.
const marks = [67, 45, 92, 58, 92, 12];
const highest = Math.max(...marks);
const lowest = Math.min(...marks);
// Safe for a list of any size, and clearer about the empty case.
const smallest = (list) => list.reduce((lo, n) => (n < lo ? n : lo), Infinity);
const largest = (list) => list.reduce((hi, n) => (n > hi ? n : hi), -Infinity);
console.log('marks ->', marks);
console.log('highest ->', highest, 'lowest ->', lowest);
console.log('spread and reduce agree:', highest === largest(marks));
console.log('Math.min() with nothing ->', Math.min(), '<- so any real number beats it');
console.log('one bad value ->', Math.max(1, 'lagos', 3));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the highest mark', highest, 92);
test('the lowest mark', lowest, 12);
test('reduce gives the same answers', [smallest(marks), largest(marks)], [12, 92]);
test('Math.min() with no arguments is Infinity', Math.min() === Infinity, true);
test('and Math.max() is minus Infinity', Math.max() === -Infinity, true);
test('careful: JSON turns Infinity into null', JSON.stringify(Math.min()), 'null');
test('an empty list falls out to the same starting value', smallest([]) === Infinity, true);
test('one non-number poisons the whole answer', Number.isNaN(Math.max(1, 'lagos', 3)), true);
Math.min() answering Infinity is not a quirk, it is the only consistent answer: the smallest of nothing must lose to every real number, and that is what Infinity does. It also makes Infinity the correct seed for a reduce.
Two traps worth carrying away. Math.max(...marks) passes one argument per element, so a list of a million numbers can blow the call stack where a reduce would not. And because these compare with < on the results of coercion, a single 'lagos' in the list makes the answer NaN silently. Note the assertion style: === Infinity against true, because JSON.stringify(Infinity) is "null" and a direct comparison would pass against null too.
55 Clamp a value into a range
easyWrite clamp(n, low, high) that pulls a number back into a range: a volume slider, a page number, a quantity field. Then find out what happens when the bounds are the wrong way round.
Stuck? Three hints, smallest first
- 1. Two calls, nested: push up to the low bound, then pull down to the high one.
- 2.
Math.min(Math.max(n, low), high)is the whole implementation. - 3. Feed it
clamp(5, 10, 0)and decide whether the answer is acceptable.
const clamp = (n, low, high) => Math.min(Math.max(n, low), high);
// The strict version refuses a range that makes no sense.
function clampChecked(n, low, high) {
if (low > high) throw new RangeError('low ' + low + ' is above high ' + high);
return clamp(n, low, high);
}
console.log('volume 130 ->', clamp(130, 0, 100));
console.log('volume -20 ->', clamp(-20, 0, 100));
console.log('volume 42 ->', clamp(42, 0, 100));
console.log('bounds swapped by mistake: clamp(5, 10, 0) ->', clamp(5, 10, 0));
let refused = '';
try { clampChecked(5, 10, 0); } catch (err) { refused = err.message; }
console.log('refused:', refused);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('above the range', clamp(130, 0, 100), 100);
test('below the range', clamp(-20, 0, 100), 0);
test('inside it, untouched', clamp(42, 0, 100), 42);
test('the bounds themselves are allowed', [clamp(0, 0, 100), clamp(100, 0, 100)], [0, 100]);
test('swapped bounds give the LOW argument, silently', clamp(5, 10, 0), 0);
test('so the checked version refuses instead', refused.includes('is above high'), true);
test('NaN survives clamping', Number.isNaN(clamp(NaN, 0, 100)), true);
test('a page number, clamped to what exists', clamp(99, 1, 12), 12);
The interesting case is clamp(5, 10, 0). Nothing throws; Math.max(5, 10) is 10, then Math.min(10, 0) is 0, so a value that was inside neither bound comes back as the high argument. If low and high can ever arrive from user input or a config file, validate them — a silent wrong answer costs more to find than an exception.
NaN passes straight through, because every comparison against NaN is false. A clamp is often the last line of defence before a value reaches a UI, so guard the input's type there rather than assuming the range check cleaned it.
56 Percentage of a total, and percentage change
easyWork out what percentage 45 marks out of 60 is, then the percentage change from one price to another. Handle a total of zero.
Stuck? Three hints, smallest first
- 1. A share of a total is
part / total * 100. - 2. A change is measured against where it started:
(to - from) / from * 100. - 3. Dividing by zero does not throw in JavaScript. Find out what it gives instead.
const percentOf = (part, total) => (total === 0 ? 0 : (part / total) * 100);
const percentChange = (from, to) => (from === 0 ? null : ((to - from) / from) * 100);
const round1 = (n) => Number(n.toFixed(1));
console.log('45 of 60 ->', percentOf(45, 60), '%');
console.log('1 of 3 ->', round1(percentOf(1, 3)), '%');
console.log('200 to 250 ->', percentChange(200, 250), '%');
console.log('250 back to 200 ->', percentChange(250, 200), '% <- not -25');
console.log('unguarded 5 / 0 ->', 5 / 0);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a clean share', percentOf(45, 60), 75);
test('a recurring one, rounded for display', round1(percentOf(1, 3)), 33.3);
test('a rise', percentChange(200, 250), 25);
test('the fall back to where you started is NOT the same size', percentChange(250, 200), -20);
test('an unguarded divide by zero gives Infinity rather than throwing', 5 / 0 === Infinity, true);
test('so the guard returns something usable', percentOf(3, 0), 0);
test('and change from zero has no meaning', percentChange(0, 50), null);
test('0 / 0 would have been NaN', Number.isNaN(0 / 0), true);
The lesson is in the fourth assertion. A 25% rise followed by a 20% fall returns you to the start, because the two percentages are measured against different bases. "Up 25%, down 25%" leaves you down overall, which is the arithmetic behind a lot of misleading price copy.
Division by zero is the other half. JavaScript does not throw: 5 / 0 is Infinity and 0 / 0 is NaN, and both then travel through the rest of your sums and out to the page as Infinity% or NaN%. Guard at the point of division, where you still know what the sensible answer is.
57 The average of a list
easyAverage an array of test scores. Then decide what your function should do with an empty array, because the obvious implementation answers NaN.
Stuck? Three hints, smallest first
- 1. Total with a reduce, then divide by the length.
- 2. An empty array gives
0 / 0. Look up what that is. - 3.
NaNis not equal to itself, soaverage(xs) === average(xs)can be false.
const scores = [67, 45, 92, 58, 78];
const mean = (xs) => xs.reduce((sum, n) => sum + n, 0) / xs.length;
// The honest version: no average exists for an empty list, so say so.
const meanOrNull = (xs) => (xs.length === 0 ? null : mean(xs));
console.log('scores ->', scores);
console.log('mean ->', mean(scores));
console.log('empty ->', mean([]), '<- 0 / 0');
console.log('is that NaN equal to itself?', mean([]) === mean([]));
console.log('floating point bites here too:', mean([0.1, 0.2, 0.3]));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the average of five scores', mean(scores), 68);
test('a single value averages to itself', mean([42]), 42);
test('an empty list gives NaN', Number.isNaN(mean([])), true);
test('and NaN is not equal to itself', mean([]) === mean([]), false);
test('so the honest version answers null', meanOrNull([]), null);
test('negatives average fine', mean([-10, 10]), 0);
test('a fractional average is kept whole, not rounded', mean([1, 2]), 1.5);
test('but decimals drift', mean([0.1, 0.2, 0.3]), 0.20000000000000004);
NaN is the only value in JavaScript that is not equal to itself, so if (avg === NaN) never fires and test('...', avg, NaN) passes vacuously because JSON.stringify(NaN) is "null". Number.isNaN(avg) is the only reliable check.
Returning null for an empty list is a design decision, not a nicety: NaN reaches the page as the string "NaN" and nobody can tell whether the data was missing or the maths was wrong. The last assertion is the reminder from exercise 51 — an average of decimals is not exact, so round at the point of display, never in the middle of a calculation.
58 Integer division and remainder
fairSplit 137 minutes into hours and minutes. Then check what % does with a negative left-hand side, because it is not what most languages' documentation leads you to expect.
Stuck? Three hints, smallest first
- 1. There is no integer-division operator. Divide, then
Math.truncorMath.floor. - 2.
%in JavaScript is a remainder, not a mathematical modulo: it takes the sign of the left operand. - 3. For a always-positive result use
((a % b) + b) % b.
// Truncating pair: matches the sign of the input, like C.
const divmod = (a, b) => [Math.trunc(a / b), a % b];
// Flooring pair: the remainder is never negative, which is what clock and
// calendar arithmetic wants.
const floorDivMod = (a, b) => [Math.floor(a / b), ((a % b) + b) % b];
console.log('137 minutes ->', divmod(137, 60), '(hours, minutes)');
console.log('-17 / 5 truncating ->', divmod(-17, 5));
console.log('-17 / 5 flooring ->', floorDivMod(-17, 5));
console.log('-7 % 3 ->', -7 % 3, ' 7 % -3 ->', 7 % -3);
console.log('3 days before Wednesday (day 3) ->', floorDivMod(3 - 3 - 3, 7)[1]);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('137 minutes is 2 hours 17', divmod(137, 60), [2, 17]);
test('the remainder takes the sign of the LEFT operand', -7 % 3, -1);
test('so a negative left side gives a negative remainder', 7 % -3, 1);
test('truncating division of a negative', divmod(-17, 5), [-3, -2]);
test('flooring division of the same numbers differs in both halves', floorDivMod(-17, 5), [-4, 3]);
test('the flooring remainder is never negative', floorDivMod(-1, 7)[1], 6);
test('which is what makes day-of-week arithmetic work backwards', floorDivMod(-3, 7)[1], 4);
test('and both agree for positives', [divmod(17, 5), floorDivMod(17, 5)], [[3, 2], [3, 2]]);
% is a remainder operator, so its sign follows the dividend: -7 % 3 is -1, not the 2 that a mathematician means by "−7 mod 3". Every wrapping bug — a day-of-week index, a carousel that will not scroll left, an angle that goes negative — is this line.
((a % b) + b) % b is the fix worth memorising: it is a modulo that always lands in 0..b-1. And since there is no integer-division operator, pick trunc or floor deliberately; as exercise 52 showed, they part company the instant a negative appears.
59 Even or odd
easyDecide whether a number is even. Make it right for negatives and for numbers that are not whole, then compare it with the bitwise trick people reach for.
Stuck? Three hints, smallest first
- 1.
n % 2 === 0for even. Test it on-4before you trust it. - 2. For odd,
n % 2 === 1is wrong on negatives. Work out what-3 % 2is. - 3.
n & 1is fast but converts to a 32-bit integer first, so it has an opinion about2.5.
const isEven = (n) => n % 2 === 0;
const isOdd = (n) => Math.abs(n % 2) === 1; // abs, because of negatives
// The tempting shortcuts, and where each one goes wrong.
const oddNaive = (n) => n % 2 === 1; // wrong for negatives
const evenBitwise = (n) => (n & 1) === 0; // wrong for fractions
const sample = [-4, -3, 0, 2.5, 7, 10];
for (const n of sample) {
console.log(String(n).padStart(5), 'even', isEven(n), ' odd', isOdd(n));
}
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a positive even', isEven(10), true);
test('zero counts as even', isEven(0), true);
test('a negative even', isEven(-4), true);
test('a negative odd', isOdd(-3), true);
test('but the naive odd test misses it', oddNaive(-3), false);
test('because -3 % 2 is -1, not 1', -3 % 2, -1);
test('2.5 is not an even number', isEven(2.5), false);
test('the bitwise version wrongly says it is', evenBitwise(2.5), true);
n % 2 === 1 is the bug that ships. It reads like the definition of odd and it is false for every negative odd number, because the remainder carries the dividend's sign — exercise 58's rule showing up in a two-line helper.
n & 1 looks clever and coerces to a 32-bit integer first, so 2.5 becomes 2 and is reported even. If the input might be fractional, or larger than 2³¹, use % and check Number.isInteger first.
60 Count the digits, then swap the outer two
easyCount how many digits a number has, then swap its first and last digit. Also exercise 29, which adds a number's digits; here they are counted and moved, and the string route has a different set of edges.
Stuck? Three hints, smallest first
- 1.
String(n).lengthcounts digits, once you have dealt with the minus sign. - 2. Swapping is easiest on an array of characters, then
joinback. - 3. Try both on
1000and on1e21before deciding you are finished.
const digitCount = (n) => String(Math.abs(Math.trunc(n))).length;
function swapEnds(n) {
const digits = String(Math.abs(n)).split('');
const last = digits.length - 1;
[digits[0], digits[last]] = [digits[last], digits[0]];
return Number(digits.join('')) * Math.sign(n || 1);
}
// The arithmetic route, for comparison. No strings involved.
const digitCountByLog = (n) => (n === 0 ? 1 : Math.floor(Math.log10(Math.abs(n))) + 1);
console.log('digits in 45678 ->', digitCount(45678));
console.log('digits in -1234 ->', digitCount(-1234));
console.log('swap 1234 ->', swapEnds(1234));
console.log('swap 1000 ->', swapEnds(1000), '<- the leading zero is gone');
console.log('digits in 1e21 ->', digitCount(1e21), '<- String(1e21) is "1e+21"');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('five digits', digitCount(45678), 5);
test('the minus sign is not a digit', digitCount(-1234), 4);
test('zero has one digit', digitCount(0), 1);
test('the log version agrees for ordinary numbers', digitCountByLog(45678), 5);
test('but log10(0) is not finite, hence the special case', Number.isFinite(Math.log10(0)), false);
test('swapping the ends', swapEnds(1234), 4231);
test('a leading zero cannot survive as a number', swapEnds(1000), 1);
test('very large numbers break the string route', digitCount(1e21), 5);
test('a single digit swaps with itself', swapEnds(7), 7);
Two edges decide whether this is correct. swapEnds(1000) should be 0001, and there is no such number — Number('0001') is 1. If the leading zero matters, the answer is text, not a number, and the function's return type has to change.
String(1e21) is "1e+21", so any digit-counting built on string length starts lying at 10²¹. Math.log10 avoids that but needs its own special case, since log10(0) is -Infinity. Neither route is wrong; each has a documented edge, and knowing which one you have chosen is the skill.
61 Why 0.1 + 0.2 is not 0.3
fairProve that 0.1 + 0.2 !== 0.3, show what the sum actually is, and find two decimals that do add up exactly.
Stuck? Three hints, smallest first
- 1. Numbers are stored in binary. One tenth in binary recurs, exactly as one third does in decimal.
- 2. Print the sum with enough decimal places to see the error:
toFixed(17). - 3. A fraction whose denominator is a power of two — a half, a quarter, an eighth — is stored exactly.
console.log('0.1 + 0.2 =', 0.1 + 0.2);
console.log('to 17 places:', (0.1 + 0.2).toFixed(17));
console.log('0.3 - 0.1 =', 0.3 - 0.1);
console.log('1.1 * 3 =', 1.1 * 3);
console.log('0.5 + 0.25 =', 0.5 + 0.25, '<- exact: both are powers of two');
console.log('in kobo: 10 + 20 =', 10 + 20);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the sum is not 0.3', 0.1 + 0.2 === 0.3, false);
test('it is a hair too big', 0.1 + 0.2, 0.30000000000000004);
test('subtraction misses low instead', 0.3 - 0.1, 0.19999999999999998);
test('and multiplication drifts too', 1.1 * 3, 3.3000000000000003);
test('halves and quarters are exact', 0.5 + 0.25 === 0.75, true);
test('0.07 * 100 is not 7', 0.07 * 100 === 7, false);
test('toFixed hides the error rather than fixing it', (0.1 + 0.2).toFixed(2), '0.30');
test('integers below 2**53 are exact, so minor units add up', 10 + 20 === 30, true);
This is not a JavaScript bug; it is IEEE 754, and Python, Java and C behave identically. A double stores a binary fraction, and one tenth in binary recurs forever, so 0.1 is really the nearest storable value slightly above it. Add two of those and the error is visible at the 17th place.
The practical consequences: never compare two computed decimals with === (exercise 62 shows the tolerance), and never hold money in naira-with-decimals. 0.07 * 100 !== 7 is that bug in miniature — a 7% rate applied to a price, then compared against a stored total that was calculated a different way. Integers are exact up to 2⁵³, so count in kobo.
62 Comparing two floats with a tolerance
fairWrite a comparison that treats 0.1 + 0.2 and 0.3 as equal, without treating 1 and 1.1 as equal. Explain why a fixed tolerance is not enough on its own.
Stuck? Three hints, smallest first
- 1.
Math.abs(a - b) < toleranceis the shape of it. - 2.
Number.EPSILONis the gap between 1 and the next representable number — it is only the right tolerance near 1. - 3. Scale the tolerance by the size of the values being compared, and you have a relative comparison.
// Absolute tolerance: fine for numbers near 1, useless for very large ones.
const closeAbs = (a, b, tol = Number.EPSILON * 4) => Math.abs(a - b) <= tol;
// Relative tolerance: the allowance grows with the magnitude of the inputs.
const nearly = (a, b, tol = 1e-9) =>
Math.abs(a - b) <= tol * Math.max(1, Math.abs(a), Math.abs(b));
console.log('0.1 + 0.2 === 0.3 ?', 0.1 + 0.2 === 0.3);
console.log('nearly(0.1 + 0.2, 0.3) ?', nearly(0.1 + 0.2, 0.3));
console.log('EPSILON is', Number.EPSILON);
console.log('1e16 + 1 === 1e16 ?', 1e16 + 1 === 1e16, '<- the gap is bigger than 1 up there');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the naive comparison fails', 0.1 + 0.2 === 0.3, false);
test('an absolute epsilon rescues it near 1', closeAbs(0.1 + 0.2, 0.3), true);
test('the relative version agrees', nearly(0.1 + 0.2, 0.3), true);
test('and neither claims 1 and 1.1 are the same', [closeAbs(1, 1.1), nearly(1, 1.1)], [false, false]);
test('EPSILON is exactly 2 to the power -52', Number.EPSILON === 2 ** -52, true);
test('past 2**53 whole numbers thin out', 2 ** 53 + 1 === 2 ** 53, true);
test('so an absolute epsilon is meaningless up there', closeAbs(2 ** 53, 2 ** 53 + 2), false);
test('while the relative one scales with the values', nearly(2 ** 53, 2 ** 53 + 2), true);
test('a value is always nearly itself', nearly(0, 0), true);
Number.EPSILON is often described as "the smallest difference JavaScript can see", which is wrong and leads to the bug in the seventh assertion. It is the gap to the next number after 1. Around 2⁵³ that gap is 2, so an absolute tolerance of 2⁻⁵² can never be met and two numbers that differ by a rounding error are reported as different.
Scaling the tolerance by the larger magnitude fixes it, which is what every test framework's toBeCloseTo does underneath. The Math.max(1, ...) in there matters too: without it, comparing values near zero would demand exactness.
63 Hold money in kobo, not naira
fairTotal a small basket twice: once in naira with decimals, once in kobo as integers. Also exercise 8, which sums a shopping list; the angle here is the unit the money is stored in.
Stuck? Three hints, smallest first
- 1. A price of ₦4.35 is 435 kobo. Store the integer, divide only when you display.
- 2.
Math.round(naira * 100), notMath.trunc— find out why with 4.35. - 3. Integers are exact up to 2⁵³, which is about 90 trillion naira in kobo. That is enough.
const toKobo = (naira) => Math.round(naira * 100);
const showNaira = (kobo) => (kobo / 100).toFixed(2);
const basketNaira = [8.7, 0.1, 1.2]; // should come to exactly 10 naira
const basketKobo = basketNaira.map(toKobo);
const totalNaira = basketNaira.reduce((a, b) => a + b, 0);
const totalKobo = basketKobo.reduce((a, b) => a + b, 0);
console.log('prices in naira ->', basketNaira);
console.log('prices in kobo ->', basketKobo);
console.log('total in naira ->', totalNaira, '<- look closely');
console.log('total in kobo ->', totalKobo, '=', showNaira(totalKobo));
console.log('free delivery over 10 naira?', totalNaira >= 10, '<- the customer disagrees');
console.log('trunc instead of round ->', Math.trunc(4.35 * 100), '<- a kobo lost');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('4.35 naira is 435 kobo', toKobo(4.35), 435);
test('because 4.35 * 100 is not exactly 435', 4.35 * 100 === 435, false);
test('so truncating loses a kobo', Math.trunc(4.35 * 100), 434);
test('the naira total misses ten naira by a hair', totalNaira === 10, false);
test('so a "spend over 10" rule refuses a basket that qualifies', totalNaira >= 10, false);
test('and printing it hides the problem completely', totalNaira.toFixed(2), '10.00');
test('the kobo total is exact', totalKobo, 1000);
test('and formats back cleanly', showNaira(totalKobo), '10.00');
The fifth assertion is the bug in its natural habitat. ₦8.70 + ₦0.10 + ₦1.20 is ten naira, but in floating point it comes to 9.999999999999998, so the free-delivery threshold rejects a basket that clearly qualifies — and the receipt still prints 10.00, so the report and the behaviour disagree and nobody can reproduce it.
Math.trunc(4.35 * 100) giving 434 is the same flaw at the boundary: 4.35 is stored slightly low, so multiplying by 100 lands just under 435. Convert once with Math.round on the way in, do all arithmetic in kobo, divide once on the way out. Every currency has a minor unit for this reason, and it is why payment APIs take integer amounts.
64 VAT at 7.5%
easyAdd Nigerian VAT of 7.5% to a price held in kobo, then recover the net price from a gross one. Work in integers throughout.
Stuck? Three hints, smallest first
- 1. VAT is
Math.round(kobo * 0.075). Round once, at the end. - 2. Going backwards from a gross figure is division by
1.075, not subtracting 7.5%. - 3. Try charging VAT per line and then on the total of those lines, and compare.
const RATE = 0.075;
const vatOn = (kobo) => Math.round(kobo * RATE);
const grossOf = (kobo) => kobo + vatOn(kobo);
const netFromGross = (gross) => Math.round(gross / (1 + RATE));
const lines = [1000, 1000, 1000]; // three items at 10 naira
const perLine = lines.reduce((sum, k) => sum + vatOn(k), 0);
const onTotal = vatOn(lines.reduce((a, b) => a + b, 0));
console.log('VAT on 2,500 naira ->', vatOn(250000), 'kobo');
console.log('gross ->', grossOf(250000), 'kobo');
console.log('back to net ->', netFromGross(268750), 'kobo');
console.log('VAT per line', perLine, 'vs VAT on the total', onTotal);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('VAT on 2,500 naira is 187.50', vatOn(250000), 18750);
test('the gross price', grossOf(250000), 268750);
test('and the net price comes back', netFromGross(268750), 250000);
test('subtracting 7.5% from the gross would be wrong', 268750 - vatOn(268750), 248594);
test('a fraction of a kobo is rounded, not dropped', vatOn(999), 75);
test('per-line VAT and VAT on the total need not agree', [perLine, onTotal], [225, 225]);
test('but they part company on awkward amounts', [vatOn(10) * 3, vatOn(30)], [3, 2]);
test('zero attracts none', vatOn(0), 0);
Two mistakes this exercise exists to prevent. Taking 7.5% off a gross price does not give the net price — the fourth assertion is 248,594 kobo where the right answer is 250,000, because the VAT was a percentage of the smaller number. Divide by 1.075 instead.
And rounding is not distributive. Three items of 10 kobo attract 1 kobo of VAT each by line, but 2 kobo on the total, so the invoice and the summary disagree by a kobo. Pick one place to round, write it down, and make every report use the same one; tax authorities care about this more than you would like.
65 A discount, and two discounts stacked
easyTake 20% off a price in kobo. Then apply 10% and another 10% and check whether that equals 20% off.
Stuck? Three hints, smallest first
- 1. The discount is
Math.round(kobo * percent / 100); subtract it. - 2. Stacked discounts multiply, so each one applies to what is left after the previous.
- 3. Two 10% discounts leave 0.9 × 0.9 of the price. Work out what that is as a single percentage.
const discount = (kobo, percent) => Math.round((kobo * percent) / 100);
const afterDiscount = (kobo, percent) => kobo - discount(kobo, percent);
const price = 1_000_000; // 10,000 naira
const twenty = afterDiscount(price, 20);
const tenTwice = afterDiscount(afterDiscount(price, 10), 10);
console.log('20% off ->', twenty, 'kobo');
console.log('10% then 10% ->', tenTwice, 'kobo <- not the same');
console.log('difference ->', tenTwice - twenty, 'kobo');
console.log('order does not matter:', afterDiscount(afterDiscount(price, 10), 20), afterDiscount(afterDiscount(price, 20), 10));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('20% off ten thousand naira', twenty, 800000);
test('two 10% discounts leave more', tenTwice, 810000);
test('the stack is really 19%, not 20%', ((price - tenTwice) / price) * 100, 19);
test('the order of two discounts does not matter', afterDiscount(afterDiscount(price, 10), 20), afterDiscount(afterDiscount(price, 20), 10));
test('a full discount is free', afterDiscount(price, 100), 0);
test('no discount changes nothing', afterDiscount(price, 0), price);
test('rounding happens on the discount, so a stray kobo stays put', afterDiscount(999, 10), 899);
test('and the parts still add back to the whole', discount(999, 10) + afterDiscount(999, 10), 999);
"10% off, plus another 10%" is 19% off, not 20%, because the second discount applies to a smaller number. Retail copy exploits this and so do bugs: applying a voucher and a sale price in two separate steps gives a different total from applying their sum, and whichever the customer sees first is the one they will argue for.
The last assertion is the invariant that keeps a receipt honest — the discount and the remainder must add back to the original. Round the discount, then subtract, rather than rounding both halves independently.
66 Simple interest and compound interest
fairOn a deposit of ₦100,000 at 10% for three years, work out the simple interest and the compound interest, and say where the gap comes from.
Stuck? Three hints, smallest first
- 1. Simple interest is the same amount every year:
principal × rate × years. - 2. Compound interest earns on the interest, so the balance is
principal × (1 + rate) ** years. - 3. Compounding more often than yearly means dividing the rate and multiplying the periods.
const principal = 100_000_00; // 100,000 naira in kobo
const rate = 0.1; // 10% a year
const simpleInterest = (kobo, r, years) => Math.round(kobo * r * years);
const compoundInterest = (kobo, r, years, perYear = 1) => {
const periods = years * perYear;
const balance = kobo * (1 + r / perYear) ** periods;
return Math.round(balance) - kobo;
};
console.log('simple, 3 years ->', simpleInterest(principal, rate, 3), 'kobo');
console.log('compound, 3 years ->', compoundInterest(principal, rate, 3), 'kobo');
console.log('compound monthly ->', compoundInterest(principal, rate, 3, 12), 'kobo');
console.log('one year, both ->', simpleInterest(principal, rate, 1), compoundInterest(principal, rate, 1));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('simple interest is 30,000 naira', simpleInterest(principal, rate, 3), 3000000);
test('compound interest is more', compoundInterest(principal, rate, 3), 3310000);
test('the gap is interest earned on interest', compoundInterest(principal, rate, 3) - simpleInterest(principal, rate, 3), 310000);
test('over one year they are identical', simpleInterest(principal, rate, 1) === compoundInterest(principal, rate, 1), true);
test('compounding monthly earns more again', compoundInterest(principal, rate, 3, 12), 3481818);
test('zero years earns nothing either way', [simpleInterest(principal, rate, 0), compoundInterest(principal, rate, 0)], [0, 0]);
test('doubling the years doubles simple interest', simpleInterest(principal, rate, 6), 2 * simpleInterest(principal, rate, 3));
test('but more than doubles compound interest', compoundInterest(principal, rate, 6) > 2 * compoundInterest(principal, rate, 3), true);
The two are the same over one period and diverge after that, because compounding pays interest on interest. Over three years at 10% the difference is ₦3,100 on ₦100,000; over thirty years it is the entire point of a savings account.
Note where the rounding sits. The balance is computed in full precision and rounded once, at the end, rather than rounded each year — rounding per period would drift, and on a real ledger it would also disagree with the bank's own statement. Compounding monthly rather than annually turns a nominal 10% into an effective 10.47%, which is why loan adverts quote the nominal rate and the small print quotes the effective one.
67 Body mass index, and the unit trap
easyCompute BMI from a weight in kilograms and a height in metres, and put the result in a band. Then see what happens when the height arrives in centimetres.
Stuck? Three hints, smallest first
- 1. BMI is weight divided by height squared, with height in metres.
- 2. A chain of ternaries reads well for bands, as long as each cut-off appears once.
- 3. A form that collects "height" will get 175 from some people and 1.75 from others. Decide what to do about it.
const bmi = (kg, metres) => kg / metres ** 2;
const band = (value) =>
value < 18.5 ? 'underweight'
: value < 25 ? 'normal'
: value < 30 ? 'overweight'
: 'obese';
// A height above 3 is centimetres, whatever the label on the field said.
const bmiForgiving = (kg, height) => bmi(kg, height > 3 ? height / 100 : height);
console.log('70kg at 1.75m ->', bmi(70, 1.75).toFixed(1), band(bmi(70, 1.75)));
console.log('50kg at 1.75m ->', bmi(50, 1.75).toFixed(1), band(bmi(50, 1.75)));
console.log('in centimetres by mistake ->', bmi(70, 175).toFixed(5));
console.log('forgiving version ->', bmiForgiving(70, 175).toFixed(1));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a normal BMI, rounded for display', Number(bmi(70, 1.75).toFixed(1)), 22.9);
test('and its band', band(bmi(70, 1.75)), 'normal');
test('a lower weight moves band', band(bmi(50, 1.75)), 'underweight');
test('exactly 25 is overweight, because the band is < 25', band(25), 'overweight');
test('centimetres give a nonsense answer rather than an error', Number(bmi(70, 175).toFixed(5)), 0.00229);
test('so the forgiving version normalises the unit', Number(bmiForgiving(70, 175).toFixed(1)), 22.9);
test('and leaves metres alone', bmiForgiving(70, 1.75) === bmi(70, 1.75), true);
test('the top band', band(bmi(110, 1.7)), 'obese');
The unit mix-up is the real subject. bmi(70, 175) does not throw, it returns 0.00229, and 0.00229 will render happily in a UI next to the word "normal" if the bands are checked in the wrong order. Range-check inputs whose unit is ambiguous, or take the unit as a separate argument.
The ternary chain works because each threshold appears exactly once and the order is ascending; that is also why band(25) is "overweight" — < 25 has already failed. Whenever a band boundary matters clinically or financially, assert both sides of it, as the fourth check does.
68 Fuel consumption, two ways round
easyA trip of 500km used 45 litres. Report the consumption both as litres per 100km and as kilometres per litre, and explain why the two rank cars in opposite directions.
Stuck? Three hints, smallest first
- 1. Litres per 100km is
litres / km * 100. - 2. Kilometres per litre is the other way up:
km / litres. - 3. One is a cost per distance, the other a distance per cost, so lower is better for one and higher for the other.
const litresPer100 = (litres, km) => (litres / km) * 100;
const kmPerLitre = (litres, km) => km / litres;
const trip = { km: 500, litres: 45 };
const round2 = (n) => Number(n.toFixed(2));
console.log('litres/100km ->', litresPer100(trip.litres, trip.km));
console.log('km/litre ->', round2(kmPerLitre(trip.litres, trip.km)));
console.log('cost at 900 naira a litre ->', trip.litres * 900_00, 'kobo');
console.log('zero distance ->', litresPer100(45, 0));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('nine litres per hundred kilometres', litresPer100(trip.litres, trip.km), 9);
test('eleven and a bit kilometres per litre', round2(kmPerLitre(trip.litres, trip.km)), 11.11);
test('the two are reciprocals, scaled by 100', round2(100 / litresPer100(45, 500)), round2(kmPerLitre(45, 500)));
test('a thirstier car scores higher on one measure', litresPer100(60, 500) > litresPer100(45, 500), true);
test('and lower on the other', kmPerLitre(60, 500) < kmPerLitre(45, 500), true);
test('fuel for the trip, in kobo', trip.litres * 90000, 4050000);
test('no distance travelled gives Infinity, not an error', litresPer100(45, 0) === Infinity, true);
test('no fuel used gives Infinity the other way', kmPerLitre(0, 500) === Infinity, true);
The same trip, two numbers, opposite directions of "better". Mixing them up in a comparison table is a classic reporting bug: sort by the wrong column and the most economical car appears worst. Name the variable after its unit — litresPer100, not consumption — and the mistake becomes hard to make.
Both divisions can take a zero denominator from real data, a trip logged before it started or a tank reading that failed. Infinity propagates quietly, so guard where you divide, as exercise 56 did.
69 Kilometres and miles
easyConvert kilometres to miles and back. Keep the conversion factor in one place, and say what the exact factor is.
Stuck? Three hints, smallest first
- 1. One mile is exactly 1.609344 kilometres, by international agreement since 1959.
- 2. Divide to go from kilometres to miles, multiply to come back.
- 3. Store the factor as a named constant; a magic 1.6 sprinkled through code is how conversions drift.
const KM_PER_MILE = 1.609344; // exact, by definition
const toMiles = (km) => km / KM_PER_MILE;
const toKm = (miles) => miles * KM_PER_MILE;
const round2 = (n) => Number(n.toFixed(2));
console.log('100 km ->', round2(toMiles(100)), 'miles');
console.log('Lagos to Ibadan, 128 km ->', round2(toMiles(128)), 'miles');
console.log('a marathon, 26.2 miles ->', round2(toKm(26.2)), 'km');
console.log('rough factor 1.6 would say', round2(100 / 1.6), 'miles');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a hundred kilometres in miles', round2(toMiles(100)), 62.14);
test('Lagos to Ibadan', round2(toMiles(128)), 79.54);
test('a marathon in kilometres', round2(toKm(26.2)), 42.16);
test('one mile of kilometres is one mile', toMiles(KM_PER_MILE), 1);
test('the round trip returns the value here', toKm(toMiles(100)), 100);
test('zero is zero in both units', [toMiles(0), toKm(0)], [0, 0]);
test('the rough 1.6 factor gives a different answer', round2(100 / 1.6) !== round2(toMiles(100)), true);
test('and the error grows with distance', round2(1000 / 1.6 - toMiles(1000)), 3.63);
The factor is exact — a mile is defined as 1609.344 metres — so any error here is yours, not the arithmetic's. Rounding it to 1.6 is out by 0.58%, and that error scales: about a third of a mile over 100km, three and a half miles over 1,000.
One named constant, used by both directions, is the point. Two hand-written factors (1.609 for one way, 0.621 for the other) drift apart and stop being inverses, so a value converted out and back no longer matches what was stored. The round trip is exact for 100 here, but do not lean on that in general — exercise 62's tolerance is the safe way to compare converted floats.
70 Kilograms, pounds and stones
easyConvert 70kg to pounds, and to stones and pounds. Return the stones-and-pounds answer as two numbers rather than a string.
Stuck? Three hints, smallest first
- 1. One pound is exactly 0.45359237 kilograms.
- 2. A stone is 14 pounds, so it is a divmod on the pounds figure.
- 3. Return
{ stones, pounds }, and let the display layer decide how to write it.
const KG_PER_LB = 0.45359237; // exact, by definition
const LB_PER_STONE = 14;
const toPounds = (kg) => kg / KG_PER_LB;
const toKg = (lb) => lb * KG_PER_LB;
function toStonesAndPounds(kg) {
const totalLb = toPounds(kg);
const stones = Math.floor(totalLb / LB_PER_STONE);
return { stones, pounds: Number((totalLb % LB_PER_STONE).toFixed(1)) };
}
const round2 = (n) => Number(n.toFixed(2));
console.log('70 kg ->', round2(toPounds(70)), 'lb');
console.log('70 kg ->', toStonesAndPounds(70));
console.log('a 50kg bag of rice ->', round2(toPounds(50)), 'lb');
console.log('154 lb back to kg ->', round2(toKg(154)));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('seventy kilograms in pounds', round2(toPounds(70)), 154.32);
test('as stones and pounds', toStonesAndPounds(70), { stones: 11, pounds: 0.3 });
test('a bag of rice', round2(toPounds(50)), 110.23);
test('and back again', round2(toKg(154)), 69.85);
test('one pound of kilograms is one pound', toPounds(KG_PER_LB), 1);
test('exactly fourteen pounds is one stone and none', toStonesAndPounds(toKg(14)), { stones: 1, pounds: 0 });
test('under a stone gives zero stones', toStonesAndPounds(toKg(9)).stones, 0);
test('zero converts to zero', toPounds(0), 0);
Returning { stones, pounds } instead of "11st 0.3lb" is the habit worth taking. A string is a dead end: it cannot be added, compared or re-formatted, and every caller that needs the numbers back has to parse it. Format at the edge, keep numbers inside.
toStonesAndPounds(toKg(14)) is the check that catches an off-by-one — 14lb must be 1 stone and 0 pounds, not 0 stones and 14. Note the toFixed(1) on the remainder: the division is float arithmetic, so the pounds part arrives with a tail of digits that would otherwise show up in the assertion.
71 Bytes in a form a person can read
fairTurn a byte count into something like 1.5 KB or 953.7 MB. Then explain why a 1,000,000,000-byte file is advertised as 1GB but shows as 953.7MB.
Stuck? Three hints, smallest first
- 1. Divide by 1024 repeatedly, counting how many times, and index into a list of unit names.
- 2. Stop when the value is under 1024 or you run out of units.
- 3. Disk makers count in powers of ten; operating systems count in powers of two. Both are "GB" in the marketing.
const BINARY = ['B', 'KB', 'MB', 'GB', 'TB'];
function human(bytes, step = 1024, units = BINARY) {
if (bytes < step) return bytes + ' ' + units[0];
let value = bytes;
let unit = 0;
while (value >= step && unit < units.length - 1) {
value /= step;
unit++;
}
return Number(value.toFixed(1)) + ' ' + units[unit];
}
const DECIMAL = ['B', 'kB', 'MB', 'GB', 'TB'];
const humanDecimal = (bytes) => human(bytes, 1000, DECIMAL);
console.log('0 ->', human(0));
console.log('1023 ->', human(1023));
console.log('1024 ->', human(1024));
console.log('1536 ->', human(1536));
console.log('1e9 ->', human(1e9), 'but the box says', humanDecimal(1e9));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('small counts stay in bytes', human(0), '0 B');
test('and right up to the boundary', human(1023), '1023 B');
test('one kilobyte', human(1024), '1 KB');
test('one and a half', human(1536), '1.5 KB');
test('a megabyte', human(1024 * 1024), '1 MB');
test('a gigabyte', human(1024 ** 3), '1 GB');
test('a billion bytes is not a binary gigabyte', human(1e9), '953.7 MB');
test('though counted in thousands it is', humanDecimal(1e9), '1 GB');
test('and the scale runs out gracefully', human(1024 ** 6), '1048576 TB');
The 953.7 is not a rounding error, it is two different definitions of the same abbreviation. A gigabyte is 10⁹ bytes to a drive manufacturer and 2³⁰ to most operating systems, a 7.4% gap that grows with each step up the scale. The unambiguous names are KiB, MiB and GiB for the binary ones; almost nobody uses them, so state your base in the note if not on screen.
Two implementation details. The loop stops one short of the end of the unit list, so an absurd input degrades to "1048576 TB" instead of reading past the array and printing undefined. And Number(value.toFixed(1)) drops a pointless trailing .0, so a whole gigabyte reads "1 GB" rather than "1.0 GB".
72 A number as Roman numerals
fairWrite 1994 as MCMXCIV. Refuse anything outside 1 to 3999.
Stuck? Three hints, smallest first
- 1. List the values largest first, including the subtractive pairs CM, CD, XC, XL, IX and IV.
- 2. Then it is a greedy loop: while the number is at least this value, append the symbol and subtract.
- 3. There is no zero and no negative in the system, so the input range is genuinely limited.
// Largest first, with the six subtractive pairs sitting in their right places.
const NUMERALS = [
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];
function toRoman(n) {
if (!Number.isInteger(n) || n < 1 || n > 3999) {
throw new RangeError('Roman numerals cover 1 to 3999, not ' + n);
}
let left = n;
let out = '';
for (const [value, symbol] of NUMERALS) {
while (left >= value) {
out += symbol;
left -= value;
}
}
return out;
}
for (const n of [4, 9, 14, 40, 1994, 2026]) console.log(String(n).padStart(4), toRoman(n));
let refused = '';
try { toRoman(0); } catch (err) { refused = err.message; }
console.log('zero:', refused);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('four is a subtractive pair', toRoman(4), 'IV');
test('nine likewise', toRoman(9), 'IX');
test('1994', toRoman(1994), 'MCMXCIV');
test('this year', toRoman(2026), 'MMXXVI');
test('the largest expressible number', toRoman(3999), 'MMMCMXCIX');
test('forty is XL, not XXXX', toRoman(40), 'XL');
test('there is no zero', refused.includes('1 to 3999'), true);
test('and no fractions', (() => { try { toRoman(2.5); return false; } catch { return true; } })(), true);
Putting the subtractive pairs into the value table is what makes the greedy loop correct. Without [900, 'CM'], 900 would come out as DCCCC — readable, historically attested, and not what anyone means by Roman numerals today.
Unlike exercise 33's number bases, this is not a place-value system: the position of a symbol changes its meaning only through the adjacency rules, which is why there is no zero and no way to write 4,000 with these seven letters. The range check is part of the answer, not decoration.
73 Roman numerals back to a number
fairRead MCMXCIV back as 1994. Then check what your reader does with IIII, and decide whether that matters.
Stuck? Three hints, smallest first
- 1. Walk the letters left to right with a lookup table of the seven values.
- 2. If a symbol is smaller than the one after it, subtract it instead of adding.
- 3. Reading is not validating: a permissive reader will happily accept strings no Roman ever wrote.
const VALUE = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
function fromRoman(text) {
const letters = text.toUpperCase().split('');
if (letters.some((ch) => !(ch in VALUE))) throw new TypeError('not a numeral: ' + text);
let total = 0;
for (let i = 0; i < letters.length; i++) {
const here = VALUE[letters[i]];
const next = VALUE[letters[i + 1]] ?? 0;
total += here < next ? -here : here; // smaller before larger means subtract
}
return total;
}
for (const r of ['IV', 'IX', 'XL', 'MCMXCIV', 'MMXXVI']) console.log(r.padEnd(9), fromRoman(r));
// Same table as exercise 72, so a round trip can be checked.
const NUMERALS = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
const toRoman = (n) => NUMERALS.reduce((out, [v, s]) => { while (n >= v) { out += s; n -= v; } return out; }, '');
const roundTrips = Array.from({ length: 3999 }, (_, i) => i + 1).every((n) => fromRoman(toRoman(n)) === n);
console.log('every number from 1 to 3999 round-trips:', roundTrips);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a subtractive pair', fromRoman('IV'), 4);
test('the long one', fromRoman('MCMXCIV'), 1994);
test('this year', fromRoman('MMXXVI'), 2026);
test('lower case is accepted', fromRoman('mmxxvi'), 2026);
test('every number from 1 to 3999 survives the round trip', roundTrips, true);
test('but IIII also reads as four, though nobody writes it', fromRoman('IIII'), 4);
test('and so does the nonsense IVI read as five', fromRoman('IVI'), 5);
test('a stray letter is refused', (() => { try { fromRoman('MZ'); return false; } catch { return true; } })(), true);
The subtractive rule is one comparison: if this symbol is worth less than the next one, it counts against the total. That single line handles all six pairs without listing them.
fromRoman('IIII') answering 4 is the lesson. Parsing and validating are different jobs, and a reader built from a lookup table accepts plenty of strings that are not well-formed numerals. If you need to reject them, the cheapest honest test is to convert back — toRoman(fromRoman(text)) === text.toUpperCase() — which is exactly what the round-trip assertion does across the whole range.
74 Print kobo as naira
fairFormat an integer number of kobo as ₦1,234,567.89. Handle a negative amount, and an amount under one naira. Also exercise 48, which groups thousands; here the input is minor units and the sign has to be placed by hand.
Stuck? Three hints, smallest first
- 1. Split the kobo into whole naira and a remainder with a divmod, then pad the remainder to two digits.
- 2. Group the naira part with
toLocaleString, passing the locale explicitly. - 3. Take the absolute value first and put the minus sign back in front of the symbol, not in front of the digits.
const SIGN = '\u20a6'; // the naira sign
function formatKobo(kobo) {
if (!Number.isInteger(kobo)) throw new TypeError('kobo must be a whole number');
const negative = kobo < 0;
const size = Math.abs(kobo);
const naira = Math.trunc(size / 100);
const rest = size % 100;
const grouped = naira.toLocaleString('en-NG');
return (negative ? '-' : '') + SIGN + grouped + '.' + String(rest).padStart(2, '0');
}
for (const k of [250000, 1, 99, 123456789, -1234500]) console.log(String(k).padStart(10), formatKobo(k));
let refused = '';
try { formatKobo(4.35); } catch (err) { refused = err.message; }
console.log('a fractional kobo:', refused);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('two and a half thousand naira', formatKobo(250000), SIGN + '2,500.00');
test('one kobo', formatKobo(1), SIGN + '0.01');
test('ninety-nine kobo', formatKobo(99), SIGN + '0.99');
test('millions are grouped', formatKobo(123456789), SIGN + '1,234,567.89');
test('a refund puts the sign outside the symbol', formatKobo(-1234500), '-' + SIGN + '12,345.00');
test('zero', formatKobo(0), SIGN + '0.00');
test('the kobo part is padded, not truncated', formatKobo(1005), SIGN + '10.05');
test('a fractional kobo is refused rather than rounded silently', refused.includes('whole number'), true);
String(rest).padStart(2, '0') is the whole reason this is written by hand: 5 kobo must print as .05, and the naive naira + '.' + rest gives 10.5, which is a hundred times the wrong amount. The padding is not cosmetic.
Taking Math.abs first and reattaching the sign keeps -₦12,345.00 rather than ₦-12,345.00. Refusing a fractional input is deliberate too: if a non-integer reaches here, some earlier code did naira arithmetic, and rounding it away at the display layer would hide that. Intl.NumberFormat with style: 'currency' does all of this for you and is the right choice in production — this version exists so the parts are visible.
75 A number written out in words
fairWrite a number up to 9999 out in English words: 2026 becomes two thousand and twenty-six. This is what a cheque or an invoice total needs.
Stuck? Three hints, smallest first
- 1. The numbers below twenty have no pattern; list them.
- 2. Twenty to ninety-nine is a tens word plus, sometimes, a hyphen and a units word.
- 3. Above that, recurse: hundreds and thousands are the same problem on a smaller number.
const ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen',
'eighteen', 'nineteen'];
const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
function inWords(n) {
if (!Number.isInteger(n) || n < 0 || n > 9999) {
throw new RangeError('this version covers 0 to 9999, not ' + n);
}
if (n < 20) return ONES[n];
if (n < 100) {
const rest = n % 10;
return TENS[Math.floor(n / 10)] + (rest ? '-' + ONES[rest] : '');
}
if (n < 1000) {
const rest = n % 100;
return ONES[Math.floor(n / 100)] + ' hundred' + (rest ? ' and ' + inWords(rest) : '');
}
const rest = n % 1000;
const thousands = inWords(Math.floor(n / 1000)) + ' thousand';
if (rest === 0) return thousands;
// "and" joins a remainder under a hundred; above that it reads as a list.
return thousands + (rest < 100 ? ' and ' : ' ') + inWords(rest);
}
for (const n of [0, 13, 42, 105, 1005, 2026, 9999]) console.log(String(n).padStart(4), inWords(n));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a small number', inWords(7), 'seven');
test('a teen, which has no pattern', inWords(13), 'thirteen');
test('a hyphenated tens word', inWords(42), 'forty-two');
test('a round hundred takes no "and"', inWords(100), 'one hundred');
test('but a hundred with a remainder does', inWords(105), 'one hundred and five');
test('a thousand with a small remainder', inWords(1005), 'one thousand and five');
test('this year', inWords(2026), 'two thousand and twenty-six');
test('the largest this version handles', inWords(9999), 'nine thousand nine hundred and ninety-nine');
test('and 3,500 needs no "and" at all', inWords(3500), 'three thousand five hundred');
The recursion is what keeps this short: once you can say a number under 100, "hundreds" and "thousands" are the same function applied to a smaller number. Extending to millions is two more lines, not a rewrite.
The placement of "and" is the fiddly part, and it is a British convention rather than a rule of arithmetic: 1,005 is "one thousand and five", while 1,105 is "one thousand one hundred and five". American English drops the "and" entirely. If this output goes on a cheque, the bank's own wording standard wins over any of it.
76 for...of and for...in are not the same loop
easyWalk the same array with for...of and with for...in, and show what each one hands you. Then add a stray property to the array and walk it again.
Stuck? Three hints, smallest first
- 1.
for...ofgives values;for...ingives keys. - 2. Check the type of what
for...ingives you before you do arithmetic on it. - 3.
array.entries()gives index and value together, with the index as a real number.
const fares = [200_00, 350_00, 150_00]; // kobo, three stops
// for...of hands you each value.
const values = [];
for (const fare of fares) values.push(fare);
// for...in hands you each KEY, and an array's keys are strings.
const keys = [];
for (const i in fares) keys.push(i);
// Which is why this looks like addition and is not.
let joined = '';
for (const i in fares) joined += i;
// A stray property rides along in for...in, but not in for...of.
const tagged = [1, 2, 3];
tagged.note = 'from the depot';
const taggedKeys = [];
for (const key in tagged) taggedKeys.push(key);
const taggedValues = [];
for (const value of tagged) taggedValues.push(value);
// entries() is the one that gives both, with a number index.
const pairs = [];
for (const [i, fare] of fares.entries()) pairs.push(i + ':' + fare);
console.log('for...in keys:', keys, 'first is a', typeof keys[0]);
console.log('with a stray property:', taggedKeys);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('for...of gives the values', values, [20000, 35000, 15000]);
test('for...in gives the keys', keys, ['0', '1', '2']);
test('and those keys are strings, not numbers', typeof keys[0], 'string');
test('so adding them concatenates instead', joined, '012');
test('a stray property shows up in for...in', taggedKeys, ['0', '1', '2', 'note']);
test('but for...of never sees it', taggedValues, [1, 2, 3]);
test('nor does length', tagged.length, 3);
test('entries() gives a real number index', pairs, ['0:20000', '1:35000', '2:15000']);
for...in walks enumerable string keys, including inherited ones and anything else bolted onto the object. On an array that means the indices arrive as '0', '1', '2' — so i + 1 gives '01', not 1, and a property someone attached to the array turns up as a fourth "index". Neither is a bug; it is what the loop is for, and an array is not what it is for.
Use for...of for values, entries() when you need the index too, and keep for...in for plain objects. Object.keys(fares) gives the same three strings, without the stray property, if you really want keys from an array.
77 while checks first, do...while checks last
easyWrite the same counting loop twice, once with while and once with do...while, then call both with a count of zero. Then use do...while for a job that must happen at least once, and show what that costs.
Stuck? Three hints, smallest first
- 1. A
whileloop can run its body zero times; ado...whilecannot. - 2. Write the count of zero down as a test case, because that is where the two differ.
- 3.
do { } while (cond);keeps its semicolon at the end.
function ticketsWhile(count) {
const printed = [];
let n = 0;
while (n < count) {
printed.push('T' + (n + 1));
n++;
}
return printed;
}
function ticketsDoWhile(count) {
const printed = [];
let n = 0;
do {
printed.push('T' + (n + 1));
n++;
} while (n < count);
return printed;
}
// Halve an amount until it is under a limit. The body has to run once, which is
// the case do...while is for -- and also the case where it bites.
function halveUntilUnder(kobo, limit) {
let value = kobo;
let rounds = 0;
do {
value = Math.floor(value / 2);
rounds++;
} while (value > limit);
return { value, rounds };
}
console.log('while, 0 tickets:', ticketsWhile(0));
console.log('do...while, 0 tickets:', ticketsDoWhile(0));
console.log('already under the limit:', halveUntilUnder(5000, 20000));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('both agree when there is work to do', ticketsWhile(3), ['T1', 'T2', 'T3']);
test('the do...while form gives the same three', ticketsDoWhile(3), ['T1', 'T2', 'T3']);
test('while checks first, so zero prints nothing', ticketsWhile(0), []);
test('do...while checks last, so zero still prints one', ticketsDoWhile(0), ['T1']);
test('and a negative count cannot save it either', ticketsDoWhile(-5), ['T1']);
test('halving down to a limit takes three rounds', halveUntilUnder(100000, 20000), { value: 12500, rounds: 3 });
test('an amount already under the limit is halved anyway', halveUntilUnder(5000, 20000), { value: 2500, rounds: 1 });
test('one round, not zero, because the guard runs after the body', halveUntilUnder(5000, 20000).rounds, 1);
The only difference between the two is where the condition sits, and it shows up in exactly one case: when the condition is false before the loop starts. while does nothing; do...while has already run the body once.
That is the whole trap in halveUntilUnder(5000, 20000). The amount was under the limit on arrival and got halved regardless, because a do...while cannot ask first. Reach for it only when the body genuinely must happen at least once — a menu prompt, a first read from a queue — and use while everywhere else. Note the semicolon after while (...): it closes a statement, and leaving it out is a syntax error rather than a silent bug, which is the one mercy here.
78 break, continue, and the loop you cannot break
easyFind the first plate on a list with a given prefix and stop looking. Then walk the whole list, skipping those plates. Then try the same early exit inside forEach.
Stuck? Three hints, smallest first
- 1.
breakleaves the loop;continueskips to the next turn. - 2. Count how many items you actually looked at, or you cannot prove
breakstopped anything. - 3. A
returninside aforEachcallback ends that call, not the walk.
const plates = ['LSD-114-AB', 'KJA-902-XY', 'BDG-771-KM', 'KJA-330-ZZ'];
// break stops the loop dead. The counter proves it.
function firstFrom(list, prefix) {
let found = null;
let looked = 0;
for (const plate of list) {
looked++;
if (plate.startsWith(prefix)) {
found = plate;
break;
}
}
return { found, looked };
}
// continue abandons this turn only.
function without(list, prefix) {
const kept = [];
for (const plate of list) {
if (plate.startsWith(prefix)) continue;
kept.push(plate);
}
return kept;
}
// In a for loop, continue still runs the i++ in the header.
let oddSum = 0;
for (let i = 1; i <= 10; i++) {
if (i % 2 === 0) continue;
oddSum += i;
}
// In a while loop the increment is yours, so it must come BEFORE the continue.
function oddsWhile(limit) {
const odds = [];
let i = 0;
while (i < limit) {
i++; // first, always, or this never finishes
if (i % 2 === 0) continue;
odds.push(i);
}
return odds;
}
// forEach cannot be broken out of. return here behaves like continue.
let calls = 0;
plates.forEach((plate) => {
calls++;
if (plate.startsWith('KJA')) return;
});
console.log('break:', firstFrom(plates, 'KJA'));
console.log('forEach visited', calls, 'plates');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('break stops at the first match', firstFrom(plates, 'KJA').found, 'KJA-902-XY');
test('and it really stopped: two read, not four', firstFrom(plates, 'KJA').looked, 2);
test('no match means the whole list is read', firstFrom(plates, 'ZZZ'), { found: null, looked: 4 });
test('continue skips and carries on', without(plates, 'KJA'), ['LSD-114-AB', 'BDG-771-KM']);
test('continue in a for loop still runs the update', oddSum, 25);
test('in a while loop the update has to come first', oddsWhile(10), [1, 3, 5, 7, 9]);
test('forEach ran for every plate despite the early return', calls, 4);
test('which is two more than the loop that could break', calls > firstFrom(plates, 'KJA').looked, true);
break is not an optimisation, it is the difference between "find one" and "look at everything". The looked counter is there so the claim is evidence rather than assertion.
Two traps. In a while loop, continue jumps straight back to the condition, so an increment written after it never runs and the loop spins forever; in a for loop the header's update clause still fires, which is why continue feels safe there and is not safe here. And forEach has no break at all — return only ends the current callback, so all four plates were visited. When you need to stop early, use for...of, or some and find, which stop by design.
79 Nested loops, and breaking out of both
fairBuild a seating chart of three rows by four seats, then find the first free seat. Leaving a nested loop early needs more than break: show what a plain break does instead.
Stuck? Three hints, smallest first
- 1. The outer loop picks the row, the inner one the seat. Push a row array per outer turn.
- 2. A plain
breakonly ends the loop it is written in. - 3. A label before the outer loop, and
break label, leaves both.
const ROWS = ['A', 'B', 'C'];
const PER_ROW = 4;
function chartOf(rows, perRow) {
const out = [];
for (const row of rows) {
const line = [];
for (let seat = 1; seat <= perRow; seat++) line.push(row + seat);
out.push(line);
}
return out;
}
const chart = chartOf(ROWS, PER_ROW);
// The body of a nested loop runs rows x seats times. Worth knowing before the
// numbers get large.
let visits = 0;
for (const row of ROWS) {
for (let seat = 1; seat <= PER_ROW; seat++) visits++;
}
// A labelled break leaves BOTH loops.
function firstFree(taken) {
let found = null;
search:
for (const row of ROWS) {
for (let seat = 1; seat <= PER_ROW; seat++) {
const label = row + seat;
if (!taken.includes(label)) {
found = label;
break search;
}
}
}
return found;
}
// The same walk with a plain break: it only ends the row, so the outer loop
// carries on and overwrites the answer with a later row's first free seat.
function firstFreeBuggy(taken) {
let found = null;
for (const row of ROWS) {
for (let seat = 1; seat <= PER_ROW; seat++) {
const label = row + seat;
if (!taken.includes(label)) {
found = label;
break;
}
}
}
return found;
}
const taken = ['A1', 'A2', 'A3', 'A4', 'B1'];
console.log(chart.map((row) => row.join(' ')).join('\n'));
console.log('labelled break:', firstFree(taken), '| plain break:', firstFreeBuggy(taken));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('three rows', chart.length, 3);
test('four seats in each', chart[0], ['A1', 'A2', 'A3', 'A4']);
test('outer index picks the row, inner one the seat', chart[1][2], 'B3');
test('twelve seats in all', chart.flat().length, 12);
test('and the inner body ran exactly twelve times', visits, 12);
test('a labelled break finds the first free seat', firstFree(taken), 'B2');
test('a plain break only leaves the row, so the walk continues', firstFreeBuggy(taken), 'C1');
test('a full bus has nothing free', firstFree(chart.flat()), null);
Nesting multiplies: three rows by four seats is twelve turns of the inner body, and doubling both quadruples the work. That is the cost to keep in view when a nested loop sits over real data.
firstFreeBuggy is the bug this exercise exists for. It looks correct, it returns a genuinely free seat, and it is wrong — break ended the inner loop only, so rows B and C were still visited and found was reassigned twice. The label reads oddly the first time you meet it, but break search is the honest fix. The alternative is to stop nesting: put the inner loop in its own function and return from it, which leaves the whole thing at once and needs no label.
80 A triangle of stars
easyPrint a left-aligned triangle of stars rows tall, then a centred pyramid. Return the lines rather than printing inside the loop, so they can be checked.
Stuck? Three hints, smallest first
- 1.
'*'.repeat(n)saves you the inner loop entirely. - 2. A pyramid row is
rows - ispaces then2 * i - 1stars. - 3. Collect lines into an array and
join('\n')once at the end.
function leftTriangle(rows) {
const lines = [];
for (let i = 1; i <= rows; i++) lines.push('*'.repeat(i));
return lines;
}
function pyramid(rows) {
const lines = [];
for (let i = 1; i <= rows; i++) {
lines.push(' '.repeat(rows - i) + '*'.repeat(2 * i - 1));
}
return lines;
}
let refused = false;
try {
'*'.repeat(-1);
} catch {
refused = true;
}
console.log(leftTriangle(4).join('\n'));
console.log(pyramid(4).join('\n'));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('four rows of stars', leftTriangle(4), ['*', '**', '***', '****']);
test('ten stars in a four-row triangle', leftTriangle(4).join('').length, 10);
test('no rows, no output, and no error', leftTriangle(0), []);
test('a pyramid pads the front with spaces', pyramid(3), [' *', ' ***', '*****']);
test('the base is 2n-1 characters wide', pyramid(5)[4].length, 9);
test('every pyramid row holds an odd number of stars', pyramid(6).every((line) => line.trim().length % 2 === 1), true);
test('printing is one join, not a loop', leftTriangle(3).join('\n'), '*\n**\n***');
test('repeat refuses a negative count rather than returning nothing', refused, true);
The classic version of this uses two nested loops and builds each line character by character. repeat replaces the inner loop, which removes the place where the off-by-one usually lives: i runs from 1 to rows and doubles as the star count.
Two details worth keeping. Building an array and joining once is both faster and testable, where console.log inside the loop leaves nothing to assert. And repeat throws a RangeError on a negative count rather than quietly giving '', so a wrong sign shows up immediately instead of printing a blank triangle.