Emmanuel Akinfulubi

JavaScript: Math, 1–40

Arithmetic, rounding, primes, factors, conversions and geometry.

Exercises 1 to 40 of 114

1 The seven arithmetic operators

easy

Take 17 and 5 and produce their sum, difference, product, quotient, remainder and power. Then work out what 2 + 3 * 4 ** 2 is without running it.

Stuck? Three hints, smallest first
  1. 1. There are six binary operators to reach for: + - * / % **.
  2. 2. ** binds tighter than *, which binds tighter than +.
  3. 3. ** is the one operator here that associates to the right.

The answer

const a = 17;
const b = 5;

const sum = a + b;
const difference = a - b;
const product = a * b;
const quotient = a / b;          // 3.4 — division never truncates in JavaScript
const remainder = a % b;         // what is left after 3 whole fives
const power = a ** 2;

// Precedence: ** first, then * / %, then + -.
const mixed = 2 + 3 * 4 ** 2;         // 4**2 = 16, 3*16 = 48, 48+2 = 50
const bracketed = (2 + 3) * 4 ** 2;   // 5*16 = 80

// ** associates to the right; everything else here goes left to right.
const tower = 2 ** 3 ** 2;            // 2 ** 9
const grouped = (2 ** 3) ** 2;        // 8 ** 2

console.log('sum, difference, product :', sum, difference, product);
console.log('quotient, remainder      :', quotient, remainder);
console.log('17 squared               :', power);
console.log('2 + 3 * 4 ** 2           :', mixed);
console.log('(2 + 3) * 4 ** 2         :', bracketed);
console.log('2 ** 3 ** 2              :', tower, 'not', grouped);

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.

9 checks

test('addition', sum, 22);
test('subtraction', difference, 12);
test('multiplication', product, 85);
test('division keeps the fraction, unlike C or Java', quotient, 3.4);
test('the remainder is what division threw away', remainder, 2);
test('and the two rebuild the original', b * Math.trunc(quotient) + remainder, 17);
test('** binds tighter than *', mixed, 50);
test('brackets override that', bracketed, 80);
test('** associates to the right, so it is 2 ** 9', [tower, grouped], [512, 64]);

Two habits come out of this. First, / is real division: 17 / 5 is 3.4, and if you wanted 3 you have to say so with Math.trunc or Math.floor. Coming from a language where integer division truncates, this is the first thing that bites.

Second, ** is the odd one out on associativity. 2 ** 3 ** 2 is 512, not 64, because it groups from the right the way the written notation does. Related trap: -2 ** 2 is a syntax error rather than -4 — the language refuses to guess, so write -(2 ** 2) or (-2) ** 2.

Answers JS Math 1 in the original set.

2 Absolute value and the gap between two numbers

easy

Return the magnitude of a number regardless of sign, then use it to write gap(a, b) giving the distance between two numbers in either order.

Stuck? Three hints, smallest first
  1. 1. Math.abs strips the sign and never returns a negative.
  2. 2. The distance between two numbers is the absolute value of their difference.
  3. 3. Math.abs coerces its argument first, so a numeric string works and a word gives NaN.

The answer

const gap = (a, b) => Math.abs(a - b);

console.log('abs(-5)      :', Math.abs(-5));
console.log('abs(5)       :', Math.abs(5));
console.log('gap(3, 10)   :', gap(3, 10), 'and the other way round:', gap(10, 3));
console.log("abs('-3')    :", Math.abs('-3'), '<- coerced to a number first');
console.log("abs('twenty'):", Math.abs('twenty'));
console.log('abs(-0)      :', Math.abs(-0), 'is it exactly +0?', Object.is(Math.abs(-0), 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.

9 checks

test('abs of a negative drops the sign', Math.abs(-5), 5);
test('abs of a positive changes nothing', Math.abs(5), 5);
test('the gap is the same in either direction', [gap(3, 10), gap(10, 3)], [7, 7]);
test('a gap across zero adds up', gap(-4, 6), 10);
test('abs coerces a numeric string', Math.abs('-3'), 3);
test('and gives NaN for something unparseable', Number.isNaN(Math.abs('twenty')), true);
test('abs of -0 is exactly +0', Object.is(Math.abs(-0), 0), true);
test('abs of -Infinity is Infinity', Math.abs(-Infinity) === Infinity, true);
test('and abs never returns anything negative', [-3, 0, 9, -0].every((n) => Math.abs(n) >= 0), true);

gap is the shape to remember: Math.abs(a - b) is symmetric, so callers cannot get the argument order wrong. Writing a > b ? a - b : b - a does the same job with a branch that can be inverted by mistake.

Math.abs coerces, which is convenient and occasionally hides a bug: a form field holding "twenty" silently becomes NaN, and NaN then poisons every later sum without throwing. If the input came from outside your code, check it with Number.isFinite before doing arithmetic on it.

Answers JS Math 2 in the original set.

3 The sign of a number, including minus zero

easy

Report -1, 0 or 1 for a number's sign. Then find the two answers Math.sign gives that are not any of those three.

Stuck? Three hints, smallest first
  1. 1. Math.sign returns a number, not a boolean.
  2. 2. It has five possible results, not three: the two extras are -0 and NaN.
  3. 3. Object.is is the only way to tell -0 from 0; === says they are equal.

The answer

console.log('sign(42)   :', Math.sign(42));
console.log('sign(-42)  :', Math.sign(-42));
console.log('sign(0)    :', Math.sign(0));
console.log('sign(-0)   :', Math.sign(-0), '<- printed as -0, and === 0 is true');
console.log('sign(NaN)  :', Math.sign(NaN));
console.log("sign('5')  :", Math.sign('5'), '<- coerced');
console.log('is it -0?  :', Object.is(Math.sign(-0), -0));
console.log('JSON sees  :', JSON.stringify(Math.sign(-0)), '<- which is why the check uses Object.is');

// sign and abs are complements: together they rebuild the number.
const rebuild = (n) => Math.sign(n) * Math.abs(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.

9 checks

test('a positive gives 1', Math.sign(42), 1);
test('a negative gives -1', Math.sign(-42), -1);
test('zero gives zero', Math.sign(0), 0);
test('but -0 gives -0, not 0 and not -1', Object.is(Math.sign(-0), -0), true);
test('=== cannot see the difference', Math.sign(-0) === 0, true);
test('and JSON prints it as 0, so never assert it that way', JSON.stringify(Math.sign(-0)), '0');
test('NaN in, NaN out', Number.isNaN(Math.sign(NaN)), true);
test('sign times abs rebuilds the number', [rebuild(-7), rebuild(7)], [-7, 7]);
test('over a list there are only two non-zero answers', [...new Set([1, 2, 3, -1, -2].map((n) => Math.sign(n)))], [1, -1]);

Math.sign is a comparison shortcut: xs.sort((a, b) => Math.sign(a - b)) reads better than the ternary, and a comparator only cares about the sign anyway.

-0 is the part worth internalising. It exists because IEEE-754 keeps the sign through an underflow, so -0.0001 * 0.0001 eventually reaches -0 rather than 0. It compares equal to 0 with ===, prints as 0 through JSON.stringify, and yet 1 / -0 is -Infinity. That is the one place it can leak into a real answer.

Answers JS Math 3 in the original set.

4 Smallest, largest, and clamping to a range

easy

Find the smallest and largest of a list, then write clamp(n, low, high). Then explain why Math.max() with no arguments is -Infinity.

Stuck? Three hints, smallest first
  1. 1. Both take any number of arguments, so an array needs spreading: Math.max(...xs).
  2. 2. They start from the identity for their comparison, which is why the empty call returns an infinity.
  3. 3. clamp is Math.min of Math.max, in that nesting.

The answer

const readings = [23, 41, 12, 38];

const clamp = (n, low, high) => Math.min(Math.max(n, low), high);

// Spread has an argument limit, so a very long array wants reduce instead.
const maxByReduce = (xs) => xs.reduce((m, n) => Math.max(m, n), -Infinity);

console.log('min      :', Math.min(...readings));
console.log('max      :', Math.max(...readings));
console.log('no args  :', Math.max(), 'and', Math.min(), '<- the identities');
console.log('with NaN :', Math.max(1, NaN, 3));
console.log('clamped  :', [clamp(5, 1, 10), clamp(-2, 1, 10), clamp(99, 1, 10)]);
console.log('by reduce:', maxByReduce(readings));

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.

9 checks

test('the smallest of the list', Math.min(...readings), 12);
test('the largest of the list', Math.max(...readings), 41);
test('Math.max with no arguments is -Infinity', Math.max() === -Infinity, true);
test('and Math.min with none is +Infinity', Math.min() === Infinity, true);
test('so the max of an empty array is -Infinity, not 0', Math.max(...[]) === -Infinity, true);
test('one NaN poisons the whole comparison', Number.isNaN(Math.max(1, NaN, 3)), true);
test('clamp leaves an in-range value alone and pulls the rest in', [clamp(5, 1, 10), clamp(-2, 1, 10), clamp(99, 1, 10)], [5, 1, 10]);
test('reduce gives the same answer without spreading', maxByReduce(readings), 41);
test('and it handles the empty case the same way', maxByReduce([]) === -Infinity, true);

The empty-call results are not a quirk, they are the identity elements: starting from -Infinity means the first real argument always wins, which is exactly how reduce has to be seeded too. It does mean Math.max(...xs) on an empty array gives -Infinity rather than throwing, and that value then flows into your chart axis or your total. Check the length first.

Two limits on the spread form. A very large array can exceed the engine's argument count and throw a RangeError — around 100k elements, engine-dependent — and one NaN anywhere makes the answer NaN, since every comparison with NaN is false. The reduce version fixes the first and inherits the second.

Answers JS Math 4 in the original set.

5 floor, ceil, round and trunc on a positive number

easy

Turn 2.7, 2.4, 2.5 and 2.1 into integers with all four rounding functions, and say which two agree on every positive input.

Stuck? Three hints, smallest first
  1. 1. floor goes towards minus infinity, ceil towards plus infinity.
  2. 2. trunc discards the fraction, which on a positive number is the same as floor.
  3. 3. round goes to the nearest, and a tie goes up.

The answer

const show = (n) => ({
  floor: Math.floor(n),
  ceil: Math.ceil(n),
  round: Math.round(n),
  trunc: Math.trunc(n),
});

console.log('2.7 ->', show(2.7));
console.log('2.4 ->', show(2.4));
console.log('2.5 ->', show(2.5), '<- a tie goes up');
console.log('2.1 ->', show(2.1));
console.log('3   ->', show(3), '<- a whole number is untouched by all four');

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.

9 checks

test('floor goes down', Math.floor(2.7), 2);
test('ceil goes up, even for 2.1', Math.ceil(2.1), 3);
test('round takes the nearest', [Math.round(2.4), Math.round(2.6)], [2, 3]);
test('and a tie rounds up', Math.round(2.5), 3);
test('trunc throws the fraction away', Math.trunc(2.7), 2);
test('on positives, trunc and floor always agree', [2.1, 2.5, 2.9].every((n) => Math.trunc(n) === Math.floor(n)), true);
test('all four leave a whole number alone', show(3), { floor: 3, ceil: 3, round: 3, trunc: 3 });
test('ceil of a whole number does not add one', Math.ceil(3), 3);
test('and each returns a number, not a string', typeof Math.round(2.5), 'number');

On positive numbers only ceil stands apart, so it is easy to believe the four are near-interchangeable. They are not, and exercise 6 is the proof. Pick by intent: floor for a page index, ceil for "how many buses do I need", round for a display value, trunc for "drop the fraction".

Math.ceil(3) being 3 matters for the bus question: Math.ceil(passengers / seats) must not add a phantom vehicle when the division comes out even.

Answers JS Math 5 in the original set.

6 The same four functions on a negative number

fair

Now round -1.7, -1.5 and -0.5. Three of the four give a different answer from the positive case. Find the one result that is -0.

Stuck? Three hints, smallest first
  1. 1. "Down" means towards minus infinity, so Math.floor(-1.7) is not -1.
  2. 2. Math.round always breaks a tie towards plus infinity, which on a negative means towards zero.
  3. 3. Math.round(-0.5) is -0. Compare it with Object.is, since -0 === 0.

The answer

const show = (n) => ({
  floor: Math.floor(n),
  ceil: Math.ceil(n),
  round: Math.round(n),
  trunc: Math.trunc(n),
});

console.log('-1.7 ->', show(-1.7));
console.log('-1.5 ->', show(-1.5), '<- the tie went towards zero');
console.log('-2.5 ->', show(-2.5));
console.log('-0.5 ->', show(-0.5), '<- round gave -0');
console.log('round(-0.5) is -0?', Object.is(Math.round(-0.5), -0));
console.log('and trunc(-0.1) is -0 as well:', Object.is(Math.trunc(-0.1), -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.

9 checks

test('floor always goes down, so -1.7 becomes -2', Math.floor(-1.7), -2);
test('ceil always goes up, so -1.7 becomes -1', Math.ceil(-1.7), -1);
test('trunc drops the fraction, giving -1', Math.trunc(-1.7), -1);
test('round takes the nearest, which really is -2', Math.round(-1.7), -2);
test('a negative tie rounds towards zero: -1.5 becomes -1', Math.round(-1.5), -1);
test('and -2.5 becomes -2, so rounding is not symmetric', [Math.round(2.5), Math.round(-2.5)], [3, -2]);
test('round(-0.5) is -0, not 0 and not -1', Object.is(Math.round(-0.5), -0), true);
test('trunc and floor disagree on every negative fraction', Math.trunc(-0.1) === Math.floor(-0.1), false);
test('while trunc and ceil agree on all of them', [-0.1, -1.5, -9.9].every((n) => Math.trunc(n) === Math.ceil(n)), true);

Math.round is specified as Math.floor(x + 0.5), and that single definition explains everything odd here: -1.5 + 0.5 is -1, so the tie moves towards plus infinity, which on the negative side is towards zero. Rounding 2.5 and -2.5 therefore gives 3 and -2, magnitudes 3 and 2. On a column of signed figures that is a real bias, which is what exercises 9 and 10 fix.

-0 leaks out of Math.round(-0.5) and Math.trunc(-0.1). It behaves as zero in arithmetic and in ===, but String(-0) is "0" while (-0).toFixed(1) is "-0.0", so a report can end up printing a negative zero. If that matters, normalise with n + 0... which does not help — use n === 0 ? 0 : n.

Answers JS Math 6 in the original set.

7 Round to a given number of decimal places

fair

Write round(n, places) returning a number. Then write toNearest(n, step) that rounds to the nearest 5, 50 or 1000. Then find the input that defeats the scaling trick.

Stuck? Three hints, smallest first
  1. 1. Scale up by 10 ** places, round, scale back down.
  2. 2. Rounding to the nearest step is Math.round(n / step) * step.
  3. 3. 1.005 is stored slightly below 1.005, so scaling by 100 lands under the halfway point.

The answer

const round = (n, places = 0) => {
  const factor = 10 ** places;
  return Math.round(n * factor) / factor;
};

const toNearest = (n, step) => Math.round(n / step) * step;

console.log('round(3.14159, 2) :', round(3.14159, 2));
console.log('round(2.5)        :', round(2.5));
console.log('round(-3.14159, 3):', round(-3.14159, 3));
console.log('round(1.005, 2)   :', round(1.005, 2), '<- expected 1.01');
console.log('why: 1.005 * 100  =', 1.005 * 100, '<- just under 100.5');
console.log('nearest 50 of 1234:', toNearest(1234, 50));
console.log('nearest 1000 naira:', toNearest(1499999, 1000));

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.

9 checks

test('two decimal places', round(3.14159, 2), 3.14);
test('no places is an ordinary round', round(2.5), 3);
test('negatives scale the same way', round(-3.14159, 3), -3.142);
test('the result is a number, not a string', typeof round(3.14159, 2), 'number');
test('but 1.005 rounds down to 1, not up to 1.01', round(1.005, 2), 1);
test('because scaling it lands a hair under the halfway point', 1.005 * 100, 100.49999999999999);
test('to the nearest 5', toNearest(13, 5), 15);
test('to the nearest 50', toNearest(1234, 50), 1250);
test('and to the nearest 1000 naira', toNearest(1499999, 1000), 1500000);

The scale-round-unscale trick is the right default, and it is not exact. It cannot be: 1.005 is not in the set of doubles, the nearest one is 1.00499999999999989..., and no amount of arithmetic recovers a digit that was never stored. Every decimal-rounding routine written in binary floating point has an input like this.

If the two-places answer must be right for money, do not round a float at all — hold kobo as integers (exercise 15) and divide only when printing. If it only has to look right, toFixed in exercise 8 rounds the decimal expansion and gets 1.005 wrong in the same direction, for the same reason.

toNearest is the more useful half of this exercise: pricing to the nearest ₦50, bucketing a chart axis, snapping a value to a grid.

Answers JS Math 7 in the original set.

8 toFixed returns a string, and toPrecision counts differently

fair

Format 3.14159 to two decimal places and check the type of what comes back. Then show that (0.615).toFixed(2) is not '0.62', and say what toPrecision counts instead.

Stuck? Three hints, smallest first
  1. 1. toFixed is a method on a number and it returns a string, padded with zeros.
  2. 2. toPrecision counts significant figures, and switches to exponential form when the number needs it.
  3. 3. Adding two toFixed results concatenates them, which is the bug this always causes.

The answer

const price = 3.14159;

console.log('toFixed(2)        :', price.toFixed(2), 'typeof', typeof price.toFixed(2));
console.log('back to a number  :', Number(price.toFixed(2)));
console.log('(5).toFixed(2)    :', (5).toFixed(2), '<- padded');
console.log('(0.615).toFixed(2):', (0.615).toFixed(2), '<- not 0.62');
console.log('the stored value  :', (0.615).toFixed(20));
console.log('toPrecision(6)    :', (1234.5678).toPrecision(6));
console.log('toPrecision(2)    :', (123456).toPrecision(2), '<- exponential');
console.log('adding two strings:', (1.5).toFixed(1) + (2.5).toFixed(1));

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.

9 checks

test('toFixed returns a string', typeof price.toFixed(2), 'string');
test('so it needs converting before arithmetic', Number(price.toFixed(2)), 3.14);
test('it pads with zeros rather than dropping them', (5).toFixed(2), '5.00');
test('0.615 to two places is 0.61, not 0.62', (0.615).toFixed(2), '0.61');
test('because what is stored is a hair under 0.615', (0.615).toFixed(20), '0.61499999999999999112');
test('1.005 fails the same way', (1.005).toFixed(2), '1.00');
test('toPrecision counts significant figures, not decimals', (1234.5678).toPrecision(6), '1234.57');
test('and switches to exponential when the figures run out', (123456).toPrecision(2), '1.2e+5');
test('adding two toFixed results concatenates them', (1.5).toFixed(1) + (2.5).toFixed(1), '1.52.5');

toFixed is for display and nothing else. The last assertion is the bug it causes in production: two subtotals formatted for a receipt, added with +, and the total is "1.52.5". Format at the very last step, never before arithmetic.

0.615 is the honest example to keep. toFixed is not sloppy — it rounds the value that is actually stored, and that value is below the halfway point, so rounding down is correct. The digits after position 17 in the toFixed(20) assertion are what the reader is usually never shown.

toPrecision answers a different question: three significant figures of 0.000123 is 0.000123, three of 123456 is 1.23e+5. Use it for scientific output, and never for money.

Answers JS Math 8 in the original set.

9 Round half away from zero

fair

Math.round sends -2.5 to -2. Write roundHalfAway so that a tie always moves away from zero, making 2.5 -> 3 and -2.5 -> -3.

Stuck? Three hints, smallest first
  1. 1. Round the magnitude, then put the sign back.
  2. 2. Math.sign(n) * Math.round(Math.abs(n)) is the whole function.
  3. 3. Check -0.5: it should give -1, where Math.round gives -0.

The answer

const roundHalfAway = (n) => Math.sign(n) * Math.round(Math.abs(n));

console.log('2.5  ->', roundHalfAway(2.5), ' Math.round says', Math.round(2.5));
console.log('-2.5 ->', roundHalfAway(-2.5), 'Math.round says', Math.round(-2.5));
console.log('-0.5 ->', roundHalfAway(-0.5), 'Math.round says', Math.round(-0.5));
console.log('-1.4 ->', roundHalfAway(-1.4), 'and -1.6 ->', roundHalfAway(-1.6));
console.log('a refund of 150.5 kobo either way:', [roundHalfAway(150.5), roundHalfAway(-150.5)]);
console.log('toFixed(0) rounds the same way:', (2.5).toFixed(0), (-2.5).toFixed(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.

9 checks

test('a positive tie still goes up', roundHalfAway(2.5), 3);
test('a negative tie now goes down to -3', roundHalfAway(-2.5), -3);
test('where Math.round gives -2', Math.round(-2.5), -2);
test('-0.5 becomes -1', roundHalfAway(-0.5), -1);
test('non-ties are unaffected', [roundHalfAway(-1.4), roundHalfAway(-1.6)], [-1, -2]);
test('zero stays zero', roundHalfAway(0), 0);
test('a charge and its refund now round to the same magnitude', [roundHalfAway(150.5), roundHalfAway(-150.5)], [151, -151]);
test('which Math.round does not manage', [Math.round(150.5), Math.round(-150.5)], [151, -150]);
test('and toFixed(0) already behaved this way', [(2.5).toFixed(0), (-2.5).toFixed(0)], ['3', '-3']);

This is the rounding most people mean by "round to the nearest": ties move away from zero, so the magnitude of a value and its negation round identically. That symmetry is what you want when a refund must mirror a charge, and it is what Math.round denies you — 150.5 and -150.5 become 151 and 150, and the half-kobo has quietly gone to the house.

Math.sign(n) * ... also handles -0 correctly by accident: Math.sign(-0) is -0, and -0 * 0 is -0, so roundHalfAway(-0) stays -0 rather than flipping sign.

Answers JS Math 9 in the original set.

10 Banker's rounding, by hand

fair

Write round-half-to-even: a tie goes to whichever neighbour is even, so 0.5 -> 0, 1.5 -> 2, 2.5 -> 2, 3.5 -> 4. Then show it preserves a total that Math.round inflates.

Stuck? Three hints, smallest first
  1. 1. Take Math.floor(n) and look at the fractional part left over.
  2. 2. Only the exact 0.5 case needs the even rule; everything else rounds normally.
  3. 3. Sum [0.5, 1.5, 2.5, 3.5] two ways and compare against the exact total of 8.

The answer

function roundHalfEven(n) {
  const low = Math.floor(n);
  const fraction = n - low;
  if (fraction > 0.5) return low + 1;
  if (fraction < 0.5) return low;
  return low % 2 === 0 ? low : low + 1;      // an exact tie: take the even side
}

const halves = [0.5, 1.5, 2.5, 3.5];
const exact = halves.reduce((s, n) => s + n, 0);
const evenTotal = halves.reduce((s, n) => s + roundHalfEven(n), 0);
const roundTotal = halves.reduce((s, n) => s + Math.round(n), 0);

console.log('half to even :', halves.map(roundHalfEven));
console.log('Math.round   :', halves.map((n) => Math.round(n)));
console.log('exact total  :', exact);
console.log('even total   :', evenTotal, '<- unchanged');
console.log('round total  :', roundTotal, '<- two too many');
console.log('negatives    :', [-1.5, -2.5].map(roundHalfEven));

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.

9 checks

test('0.5 goes down to the even 0', roundHalfEven(0.5), 0);
test('1.5 goes up to the even 2', roundHalfEven(1.5), 2);
test('2.5 stays at the even 2, where Math.round gives 3', [roundHalfEven(2.5), Math.round(2.5)], [2, 3]);
test('3.5 goes up to the even 4', roundHalfEven(3.5), 4);
test('non-ties round to the nearest as usual', [roundHalfEven(2.4), roundHalfEven(2.6)], [2, 3]);
test('negative ties take the even neighbour too', [roundHalfEven(-1.5), roundHalfEven(-2.5)], [-2, -2]);
test('the four halves really total 8', exact, 8);
test('half-to-even preserves that total', evenTotal, 8);
test('while Math.round inflates it to 10', roundTotal, 10);

Half-up has a bias: over a long column of ties it gains half a unit every time, and the assertions show four values gaining 2. Half-to-even splits the ties evenly between up and down, so the bias cancels. That is why it is the default in IEEE-754 arithmetic, in Python's round, and in most accounting rules — hence the name.

The implementation detail worth noting is fraction === 0.5 only fires on an exactly representable half, which is the only case where it can. 0.5, 1.5, 2.5 are all exact doubles; 1.005 is not, so it never reaches the tie branch and rounds by magnitude instead. That is correct, not a gap.

Answers JS Math 10 in the original set.

11 Why 0.1 + 0.2 is not 0.3

fair

Show that 0.1 + 0.2 === 0.3 is false, print what the sum really is, and find two decimal fractions that do add up exactly.

Stuck? Three hints, smallest first
  1. 1. A double stores a binary fraction. A tenth is 0.0001100110011... in binary, repeating forever.
  2. 2. toFixed(20) shows more of the stored value than the default printing does.
  3. 3. A fraction whose denominator is a power of two — a half, a quarter, an eighth — is stored exactly.

The answer

console.log('0.1 + 0.2      :', 0.1 + 0.2);
console.log('=== 0.3        :', 0.1 + 0.2 === 0.3);
console.log('the difference :', 0.1 + 0.2 - 0.3);
console.log('0.1 really is  :', (0.1).toFixed(20));
console.log('0.5 really is  :', (0.5).toFixed(20), '<- exact, it is a power of two');
console.log('left to right  :', 0.1 + 0.2 + 0.3, 'against', 0.3 + 0.2 + 0.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.

8 checks

test('the comparison everyone tries first is false', 0.1 + 0.2 === 0.3, false);
test('the sum is a hair over three tenths', 0.1 + 0.2, 0.30000000000000004);
test('the error is about 5.5e-17', 0.1 + 0.2 - 0.3, 5.551115123125783e-17);
test('0.1 is not exactly a tenth to begin with', (0.1).toFixed(20), '0.10000000000000000555');
test('so addition is not even associative here', [0.1 + 0.2 + 0.3, 0.3 + 0.2 + 0.1], [0.6000000000000001, 0.6]);
test('halves and quarters are exact, being powers of two', 0.5 + 0.25 === 0.75, true);
test('and integers below 2 ** 53 are exact as well', 0.1 * 10 === 1, true);
test('rounding the result to two places hides it', Math.round((0.1 + 0.2) * 100) / 100, 0.3);

Nothing is broken. A double has 53 bits of mantissa, one tenth in binary repeats forever, so 0.1 is stored as the nearest available double — a value slightly above a tenth. Two of those, added, land above three tenths, and 0.3 is a different nearest-double. Every language with IEEE-754 doubles does this; JavaScript is only unusual in having no separate integer type to hide behind.

The associativity assertion is the practical one. 0.1 + 0.2 + 0.3 and 0.3 + 0.2 + 0.1 differ, so summing the same figures in a different order can give a different total — which is why a report and its subtotal can disagree by a penny with no bug anywhere. Exercises 12 to 15 cover the three ways out: tolerance, integers, and compensated summation.

Answers JS Math 11 in the original set.

12 Number.EPSILON and comparing with a tolerance

fair

Write close(a, b) using an absolute tolerance, then closeRel(a, b) using a relative one, and find the input where the absolute version gives the wrong answer.

Stuck? Three hints, smallest first
  1. 1. Number.EPSILON is the gap between 1 and the next representable double.
  2. 2. An absolute tolerance of 1e-9 is meaningless at a magnitude of 1e16, where consecutive doubles are 2 apart.
  3. 3. A relative tolerance scales the allowance by the size of the numbers being compared.

The answer

const close = (a, b, tol = 1e-9) => Math.abs(a - b) < tol;

const closeRel = (a, b, tol = 1e-9) =>
  Math.abs(a - b) <= tol * Math.max(Math.abs(a), Math.abs(b), 1);

// A thousand additions of a thousandth: the error is now wider than EPSILON.
let accumulated = 0;
for (let i = 0; i < 1000; i++) accumulated += 0.001;

console.log('EPSILON              :', Number.EPSILON);
console.log('1 + EPSILON === 1    :', 1 + Number.EPSILON === 1);
console.log('1 + EPSILON/2 === 1  :', 1 + Number.EPSILON / 2 === 1, '<- it vanished');
console.log('close(0.1+0.2, 0.3)  :', close(0.1 + 0.2, 0.3));
console.log('close(1e16, 1e16+2)  :', close(1e16, 1e16 + 2), '<- 2 apart, and adjacent doubles');
console.log('closeRel(1e16, +2)   :', closeRel(1e16, 1e16 + 2));
console.log('closeRel(1, 1.1)     :', closeRel(1, 1.1), '<- still refuses real differences');
console.log('1000 x 0.001         :', accumulated, 'off by', Math.abs(accumulated - 1));

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.

9 checks

test('EPSILON is the gap above 1', Number.EPSILON, 2.220446049250313e-16);
test('adding it changes the number', 1 + Number.EPSILON === 1, false);
test('adding half of it does not', 1 + Number.EPSILON / 2 === 1, true);
test('an absolute tolerance settles 0.1 + 0.2', close(0.1 + 0.2, 0.3), true);
test('but at 1e16 two doubles differ by 2, so it says not close', close(1e16, 1e16 + 2), false);
test('a relative tolerance handles both magnitudes', [closeRel(0.1 + 0.2, 0.3), closeRel(1e16, 1e16 + 2)], [true, true]);
test('and still refuses genuinely different numbers', closeRel(1, 1.1), false);
test('EPSILON is wide enough for a single addition', close(0.1 + 0.2, 0.3, Number.EPSILON), true);
test('but too tight once a thousand additions have accumulated', close(accumulated, 1, Number.EPSILON), false);

Number.EPSILON is often described as "the tolerance to compare floats with", and the last assertion shows why that is wrong. It is the spacing of doubles near 1. Values that have been through several operations accumulate more error than that, and values far from 1 have a much wider spacing — near 1e16, consecutive doubles are 2 apart, so asking for agreement within 1e-9 is asking for bit equality.

The rule of thumb: pick a tolerance from the domain, not from the machine. Money to the kobo means 0.005; a geometry test means something like 1e-9 relative. The Math.max(..., 1) floor in closeRel keeps it sensible when both values are near zero, where a purely relative test would demand exactness.

Answers JS Math 12 in the original set.

13 A gallery of floating-point surprises

fair

Assert the exact value of 0.1 * 3, 0.3 - 0.1, 4.35 * 100 and 0.07 * 100. Then find why Math.floor((0.1 + 0.7) * 10) is 7.

Stuck? Three hints, smallest first
  1. 1. Print each result before asserting it. The exact double is what you must write down.
  2. 2. Multiplying a price by 100 to get minor units is the single most common victim.
  3. 3. Math.floor on a value that is a hair under 8 gives 7, and there is no warning.

The answer

console.log('0.1 * 3        :', 0.1 * 3);
console.log('0.3 - 0.1      :', 0.3 - 0.1);
console.log('1.1 * 3        :', 1.1 * 3);
console.log('4.35 * 100     :', 4.35 * 100, '<- kobo, apparently');
console.log('0.07 * 100     :', 0.07 * 100, '<- 7% of 100');
console.log('0.1 + 0.7      :', 0.1 + 0.7);
console.log('times 10       :', (0.1 + 0.7) * 10);
console.log('floored        :', Math.floor((0.1 + 0.7) * 10), '<- the classic off-by-one');
console.log('rounded instead:', Math.round((0.1 + 0.7) * 10));

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.

9 checks

test('a tenth times three overshoots', 0.1 * 3, 0.30000000000000004);
test('and this subtraction undershoots', 0.3 - 0.1, 0.19999999999999998);
test('1.1 * 3 is visibly wrong', 1.1 * 3, 3.3000000000000003);
test('a price times 100 is not the kobo count you wanted', 4.35 * 100, 434.99999999999994);
test('so it needs rounding before being treated as an integer', Math.round(4.35 * 100), 435);
test('7 percent of 100 is not 7', 0.07 * 100, 7.000000000000001);
test('0.1 + 0.7 loses the last digit', 0.1 + 0.7, 0.7999999999999999);
test('so flooring the tenfold gives 7, not 8', Math.floor((0.1 + 0.7) * 10), 7);
test('while rounding it gives 8', Math.round((0.1 + 0.7) * 10), 8);

The direction of the error is not predictable. 4.35 * 100 comes out low and 0.07 * 100 comes out high, so "add a tiny amount before flooring" is not a fix, it is a different bug. Math.round is the right tool whenever you are converting a decimal quantity to an integer count, because the true value is always within half a unit.

Math.floor((0.1 + 0.7) * 10) is the canonical example because it looks like integer arithmetic. Anywhere you compute a bucket index, a page number or a count of minor units by flooring a product, this is waiting. Round, or keep integers from the start.

Answers JS Math 13 in the original set.

14 Error accumulating over a loop

fair

Add 0.1 to a running total ten times and a hundred times. Then fix it twice: once by counting in integers, once with compensated summation.

Stuck? Three hints, smallest first
  1. 1. Ten additions leave the total just under 1; a hundred leave it further out.
  2. 2. Counting integers and dividing once at the end has no accumulation at all.
  3. 3. Kahan summation keeps a separate running record of the bits that were dropped.

The answer

let ten = 0;
for (let i = 0; i < 10; i++) ten += 0.1;

let hundred = 0;
for (let i = 0; i < 100; i++) hundred += 0.1;

// Fix one: count in integers, divide once.
const tenths = (count) => count / 10;

// Fix two: Kahan summation. `c` carries the low-order bits that were lost.
function kahan(xs) {
  let sum = 0;
  let c = 0;
  for (const x of xs) {
    const y = x - c;
    const t = sum + y;
    c = t - sum - y;
    sum = t;
  }
  return sum;
}

// A float loop counter that tests for equality may never stop, so this one is bounded.
const steps = (() => {
  let x = 0;
  let n = 0;
  while (x !== 1 && n < 20) { x += 0.1; n++; }
  return n;
})();

console.log('ten additions    :', ten, ' === 1?', ten === 1);
console.log('hundred additions:', hundred);
console.log('integers instead :', tenths(10), tenths(100));
console.log('kahan            :', kahan(new Array(10).fill(0.1)));
console.log('a while (x !== 1) loop ran', steps, 'times without ever hitting 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.

8 checks

test('adding a tenth ten times does not reach 1', ten === 1, false);
test('it stops just short', ten, 0.9999999999999999);
test('a hundred times, the error is larger', hundred, 9.99999999999998);
test('counting integers and dividing once is exact', [tenths(10), tenths(100)], [1, 10]);
test('so is multiplying instead of accumulating', 0.1 * 10 === 1, true);
test('compensated summation recovers the 1', kahan(new Array(10).fill(0.1)), 1);
test('and the 10', kahan(new Array(100).fill(0.1)), 10);
test('a loop that waits for exactly 1 never gets there', steps, 20);

Error does not stay put: each addition rounds, and the roundings compound. Over a hundred additions the total is out by 2e-14, which is invisible until it is a balance sheet.

The integer fix is the one to reach for. Loop over i, derive the value as i / 10, and there is one rounding rather than a hundred. The same rule governs loop conditions: for (let x = 0; x !== 1; x += 0.1) is an infinite loop, and even x < 1 runs a different number of times than you counted. Loop over integers and scale inside the body.

Kahan summation is worth knowing exists. It costs three extra operations per element and recovers most of the lost precision, which is why numerical libraries use it for long sums. It is not magic — it cannot fix 0.1 not being a tenth — it only stops the accumulation of that error.

Answers JS Math 14 in the original set.

15 Money belongs in integer kobo

fair

Hold three prices as integer kobo, add them, apply 7.5% VAT and format the total as naira. Then show a set of naira prices whose float total is wrong.

Stuck? Three hints, smallest first
  1. 1. One naira is 100 kobo. Convert once at the boundary with Math.round(naira * 100).
  2. 2. Every sum, discount and split happens on the integers; divide by 100 only for display.
  3. 3. 19.99 + 5.75 + 3.4 is the kind of total that does not come out exact.

The answer

const toKobo = (naira) => Math.round(naira * 100);
const naira = (kobo) => (kobo / 100).toFixed(2);

const prices = [19.99, 5.75, 3.4];
const inKobo = prices.map(toKobo);                 // [1999, 575, 340]
const subtotal = inKobo.reduce((a, b) => a + b, 0);
const vat = Math.round(subtotal * 0.075);          // 7.5%, to the nearest kobo
const total = subtotal + vat;

const floatTotal = prices.reduce((a, b) => a + b, 0);

console.log('prices     :', prices);
console.log('as kobo    :', inKobo);
console.log('subtotal   :', subtotal, 'kobo =', naira(subtotal));
console.log('VAT        :', vat, 'kobo');
console.log('total      :', total, 'kobo =', naira(total));
console.log('float total:', floatTotal, '<- and it should be 29.14');
console.log('a price times 100 direct:', 19.99 * 100);

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.

9 checks

test('each price becomes an exact integer of kobo', inKobo, [1999, 575, 340]);
test('the naive multiply would not have', 19.99 * 100, 1998.9999999999998);
test('adding integers is exact', subtotal, 2914);
test('where adding the floats is not', floatTotal, 29.139999999999997);
test('so the float total fails an equality check against 29.14', floatTotal === 29.14, false);
test('VAT at 7.5% of 2914 kobo, to the nearest kobo', vat, 219);
test('and the total stays an integer', total, 3133);
test('formatted for a receipt', naira(total), '31.33');
test('every intermediate value is a safe integer', [subtotal, vat, total].every(Number.isSafeInteger), true);

The rule is: convert to minor units at the edge of the system, do all arithmetic in integers, and format only when the value leaves. Integers below 253 are exact, so no sum, discount or split can drift — and 253 kobo is about ₦90 trillion, so the headroom is not the problem.

Math.round(naira * 100) is doing real work in the first assertion: 19.99 * 100 is 1998.9999999999998, and truncating that would lose a kobo on every conversion. Round, never truncate, when crossing from decimal to minor units.

Percentages are the remaining decision. Math.round(subtotal * 0.075) rounds VAT to the nearest kobo, which is a policy choice — exercise 106 shows the version where the rounded parts must add back to a fixed total.

Answers JS Math 15 in the original set.

16 Overflow to Infinity, underflow to zero

fair

Find the largest and smallest positive doubles, then push past both. Then check how Infinity and NaN behave in arithmetic.

Stuck? Three hints, smallest first
  1. 1. Number.MAX_VALUE and Number.MIN_VALUE are the outer edges, and MIN_VALUE is not negative.
  2. 2. Overflow gives Infinity silently; underflow gives 0 just as silently.
  3. 3. JSON.stringify(Infinity) is "null", so assert it with === Infinity.

The answer

console.log('MAX_VALUE      :', Number.MAX_VALUE);
console.log('doubled        :', Number.MAX_VALUE * 2);
console.log('MIN_VALUE      :', Number.MIN_VALUE, '<- smallest positive, not most negative');
console.log('halved         :', Number.MIN_VALUE / 2, '<- gone');
console.log('2 ** 1023      :', 2 ** 1023);
console.log('2 ** 1024      :', 2 ** 1024);
console.log('through JSON   :', JSON.stringify({ big: Infinity, bad: NaN }));
console.log('Infinity - Infinity:', Infinity - Infinity);

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.

9 checks

test('the largest finite double', Number.MAX_VALUE, 1.7976931348623157e+308);
test('doubling it overflows silently to Infinity', Number.MAX_VALUE * 2 === Infinity, true);
test('the smallest positive double is denormal', Number.MIN_VALUE, 5e-324);
test('halving that underflows to plain zero', Number.MIN_VALUE / 2, 0);
test('2 ** 1024 is already past the top', 2 ** 1024 === Infinity, true);
test('while 2 ** 1023 is still finite', Number.isFinite(2 ** 1023), true);
test('Infinity minus Infinity is NaN, not 0', Number.isNaN(Infinity - Infinity), true);
test('1 / Infinity is 0, and 1 / -0 is -Infinity', [1 / Infinity, 1 / -0 === -Infinity], [0, true]);
test('JSON turns both Infinity and NaN into null', JSON.stringify({ big: Infinity, bad: NaN }), '{"big":null,"bad":null}');

Both edges fail quietly. Overflow gives Infinity, which then propagates through every later operation; underflow gives 0, which usually looks like a legitimate answer and only shows up when something divides by it. Neither throws. Number.isFinite after a chain of multiplications is cheap insurance.

The JSON assertion is the one that catches people in an API. Infinity and NaN are not JSON values, so they serialise as null, and the receiving end sees a missing number rather than a broken one. That is also why every assertion in this section reaches for === Infinity or Number.isNaN: through JSON.stringify, which is how test compares, both would otherwise match anything else that serialises to null.

Answers JS Math 16 in the original set.

17 Number.isInteger, isFinite and isNaN

easy

Test whether a value is a whole number, a finite number, or NaN. Then show what the global isNaN and isFinite do that the Number. versions refuse to.

Stuck? Three hints, smallest first
  1. 1. The Number. methods do no coercion: a string is never a number to them.
  2. 2. The global isNaN(‘abc’) is true because it converts first.
  3. 3. NaN === NaN is false, which is why a dedicated test exists at all.

The answer

console.log('isInteger(5)      :', Number.isInteger(5));
console.log('isInteger(5.0)    :', Number.isInteger(5.0), '<- there is no separate float type');
console.log('isInteger(5.5)    :', Number.isInteger(5.5));
console.log("isInteger('5')    :", Number.isInteger('5'), '<- a string is not a number');
console.log("isNaN('abc')      :", isNaN('abc'), 'against Number.isNaN:', Number.isNaN('abc'));
console.log("isFinite('42')    :", isFinite('42'), 'against Number.isFinite:', Number.isFinite('42'));
console.log('NaN === NaN       :', NaN === NaN);
console.log('Object.is(NaN,NaN):', Object.is(NaN, NaN));

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.

9 checks

test('a whole number is an integer', Number.isInteger(5), true);
test('and so is 5.0, since the two are the same value', Number.isInteger(5.0), true);
test('5.5 is not', Number.isInteger(5.5), false);
test('a numeric string is not a number to these methods', Number.isInteger('5'), false);
test('the global isNaN coerces first, Number.isNaN does not', [isNaN('abc'), Number.isNaN('abc')], [true, false]);
test('the same split applies to isFinite', [isFinite('42'), Number.isFinite('42')], [true, false]);
test('NaN is the one value not equal to itself', NaN === NaN, false);
test('so this is how you detect it', Number.isNaN(0 / 0), true);
test('or with Object.is, which does see it', Object.is(NaN, NaN), true);

Prefer the Number. methods. The globals were specified before undefined was even reliable and they coerce, so isNaN('') is false — an empty string converts to 0 — while isNaN('abc') is true. That is a type conversion masquerading as a numeric test, and it turns a validation bug into a silent pass.

Number.isInteger(5.0) being true is not a rounding: JavaScript has one numeric type, and 5.0 and 5 are the same double. There is no way to ask "was this written with a decimal point".

Answers JS Math 17 in the original set.

18 MAX_SAFE_INTEGER, and what breaks past it

fair

Print Number.MAX_SAFE_INTEGER, then show two different integers that compare equal past that limit. Then check what Number.isInteger says about them.

Stuck? Three hints, smallest first
  1. 1. The limit is 2 ** 53 - 1, because a double has 53 bits of mantissa.
  2. 2. Past it, only every second integer is representable, so x + 1 and x + 2 can land on the same double.
  3. 3. Number.isSafeInteger is the test; Number.isInteger answers a different question.

The answer

const max = Number.MAX_SAFE_INTEGER;

console.log('MAX_SAFE_INTEGER :', max);
console.log('is 2 ** 53 - 1   :', max === 2 ** 53 - 1);
console.log('max + 1          :', max + 1);
console.log('max + 2          :', max + 2, '<- the same double');
console.log('2 ** 53 + 1      :', 2 ** 53 + 1, '<- did not move');
console.log('a 16-digit id    :', Number('9007199254740993'));
console.log('isSafeInteger    :', Number.isSafeInteger(2 ** 53 - 1), Number.isSafeInteger(2 ** 53));
console.log('isInteger(2**60) :', Number.isInteger(2 ** 60), '<- still whole, just not safe');

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.

9 checks

test('the safe limit', max, 9007199254740991);
test('which is 2 ** 53 - 1', max === 2 ** 53 - 1, true);
test('one past it, adding 1 and adding 2 give the same number', max + 1 === max + 2, true);
test('and 2 ** 53 equals itself plus one', (2 ** 53) === (2 ** 53) + 1, true);
test('isSafeInteger draws the line in the right place', [Number.isSafeInteger(2 ** 53 - 1), Number.isSafeInteger(2 ** 53)], [true, false]);
test('a 16-digit id read as a number loses its last digit', Number('9007199254740993'), 9007199254740992);
test('so two distinct ids can collide', Number('9007199254740993') === Number('9007199254740992'), true);
test('isInteger still says yes, because the value is whole', Number.isInteger(2 ** 60), true);
test('the string form, by contrast, keeps every digit', '9007199254740993'.length, 16);

A double carries 53 bits of mantissa, so every integer up to 253 is representable and beyond that the gaps grow: first every second integer, then every fourth. Nothing throws. max + 1 === max + 2 is the failure in its clearest form.

This is why identifiers from a database, a payment reference or a Twitter-style snowflake id should be carried as strings. JSON.parse will happily turn 9007199254740993 into a number and lose the last digit, and the two ids in the assertion then compare equal — a bug that shows up as one customer seeing another's record. Keep ids as text, or use BigInt (exercise 19).

Answers JS Math 18 in the original set.

19 BigInt for exact large arithmetic

fair

Compute 2 ** 64 exactly with BigInt. Then find three things a BigInt refuses to do that a Number allows.

Stuck? Three hints, smallest first
  1. 1. A BigInt literal ends in n, and typeof reports ‘bigint’.
  2. 2. Mixing a BigInt and a Number in arithmetic throws a TypeError.
  3. 3. There is no fractional BigInt, so division truncates, and JSON.stringify refuses it outright.

The answer

const big = 2n ** 64n;

const mixed = (() => {
  try { return String(1n + 1); } catch (e) { return e.constructor.name; }
})();

const jsonRefused = (() => {
  try { return JSON.stringify({ big: 1n }); } catch (e) { return e.constructor.name; }
})();

console.log('2n ** 64n      :', big.toString());
console.log('typeof         :', typeof big);
console.log('exact past 2^53:', (2n ** 53n + 1n).toString());
console.log('as a Number    :', (2 ** 53) + 1, '<- did not move');
console.log('1n + 1         :', mixed);
console.log('7n / 2n        :', (7n / 2n).toString(), '<- truncated, no fraction exists');
console.log('JSON.stringify :', jsonRefused);
console.log('loose vs strict:', 10n == 10, 10n === 10);

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.

9 checks

test('a BigInt literal ends in n', typeof 10n, 'bigint');
test('and it is exact past 2 ** 53', 2n ** 53n + 1n === 9007199254740993n, true);
test('where a double is not', (2 ** 53) + 1 === 2 ** 53, true);
test('mixing a BigInt with a Number throws', mixed, 'TypeError');
test('loose equality does cross the types', 10n == 10, true);
test('strict equality does not', 10n === 10, false);
test('division truncates, since there are no fractions', 7n / 2n === 3n, true);
test('JSON.stringify refuses a BigInt outright', jsonRefused, 'TypeError');
test('so send it as a string', String(big), '18446744073709551616');

BigInt is arbitrary-precision integers, and the deliberate friction is the point: refusing to mix with Number means you can never accidentally lose precision by adding a float. Convert explicitly with BigInt(n) or Number(b) and take responsibility at that line.

Three practical limits. Division truncates, so BigInt is no use for money with fractions unless you scale to minor units first — which you should be doing anyway. JSON.stringify throws rather than guessing, so a BigInt in an API payload needs converting to a string by hand. And Math.* functions do not accept BigInts at all: Math.sqrt(4n) throws.

Answers JS Math 19 in the original set.

20 parseInt, Number and unary plus

fair

Convert '42', '42abc', '0.9', '' and ' 12 ' to numbers three ways and tabulate what each does. Then find the one that reads '1e3' as 1.

Stuck? Three hints, smallest first
  1. 1. Number converts the whole string or gives NaN.
  2. 2. parseInt reads as far as it can and stops, so trailing rubbish is ignored.
  3. 3. parseInt also stops at the decimal point and at the e of exponential notation.

The answer

const inputs = ['42', '42abc', '0.9', '', '  12  ', '1e3'];

const table = inputs.map((s) => ({
  input: JSON.stringify(s),
  number: Number(s),
  parseInt: parseInt(s, 10),
  plus: +s,
}));

for (const row of table) console.log(row);

console.log('Number(null)      :', Number(null), 'Number(undefined):', Number(undefined));
console.log('Number(true)      :', Number(true), 'Number([])       :', Number([]));

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.

9 checks

test('Number reads the whole string or fails', [Number('42'), Number.isNaN(Number('42abc'))], [42, true]);
test('parseInt reads as far as it can', parseInt('42abc', 10), 42);
test('parseInt stops at the decimal point', parseInt('0.9', 10), 0);
test('where Number keeps the fraction', Number('0.9'), 0.9);
test('unary plus is Number in one character', +'3.5', 3.5);
test('an empty string is 0 to Number but NaN to parseInt', [Number(''), Number.isNaN(parseInt('', 10))], [0, true]);
test('null is 0, undefined is NaN', [Number(null), Number.isNaN(Number(undefined))], [0, true]);
test('surrounding whitespace is trimmed by all of them', [Number('  12  '), parseInt('  12  ', 10), +'  12  '], [12, 12, 12]);
test('but only parseInt reads 1e3 as 1', [parseInt('1e3', 10), Number('1e3')], [1, 1000]);

Choose by what a bad input should do. Number and + are strict about the whole string, so "42abc" becomes NaN and you can reject it. parseInt is lenient by design — it exists to read a prefix, which is right for "12px" and wrong for a form field, where "42abc" silently becomes 42.

Number('') being 0 is the trap that outlives all the others. An empty input, an absent value, a cleared field: all become zero, and a zero total looks plausible. Validate the string before converting, or check s.trim() !== '' first.

Answers JS Math 20 in the original set.

21 parseInt and the radix

fair

Read '101' as binary, 'ff' as hex and '08' as decimal. Then explain why ['10', '10', '10'].map(parseInt) does not give three tens.

Stuck? Three hints, smallest first
  1. 1. parseInt takes a second argument: the base, from 2 to 36.
  2. 2. Without it, only a 0x prefix changes the base; a leading zero no longer means octal.
  3. 3. map passes the index as the second argument, and parseInt reads that as the radix.

The answer

const mapped = ['10', '10', '10'].map(parseInt);   // radix 0, then 1, then 2

console.log("parseInt('101', 2) :", parseInt('101', 2));
console.log("parseInt('ff', 16) :", parseInt('ff', 16));
console.log("parseInt('0x1f')   :", parseInt('0x1f'), '<- prefix honoured with no radix');
console.log("parseInt('08')     :", parseInt('08'), '<- decimal, not octal');
console.log("parseInt('8', 8)   :", parseInt('8', 8), '<- 8 is not a base-8 digit');
console.log("parseInt('129', 8) :", parseInt('129', 8), "<- read '12', stopped at the 9");
console.log('map(parseInt)      :', mapped);

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.

9 checks

test('binary needs radix 2', parseInt('101', 2), 5);
test('hex needs radix 16, or a 0x prefix', [parseInt('ff', 16), parseInt('0xff', 16)], [255, 255]);
test('a 0x prefix is honoured even with no radix given', parseInt('0x1f'), 31);
test('a leading zero is decimal in modern engines', parseInt('08'), 8);
test('8 is not a base-8 digit, so that parse gives NaN', Number.isNaN(parseInt('8', 8)), true);
test('a digit beyond the radix stops the scan', parseInt('129', 8), 10);
test('radix 1 and radix 37 are out of range', [Number.isNaN(parseInt('1', 1)), Number.isNaN(parseInt('1', 37))], [true, true]);
test('map hands parseInt the index, so the second call gets radix 1', mapped.map(Number.isNaN), [false, true, false]);
test('and the third is read as binary', mapped[2], 2);

Always pass the radix. The historical reason was octal — parseInt('08') used to be 0 in some engines, which broke every zero-padded date and time string ever written. Modern engines default to 10, but the habit costs one argument and removes the question.

['10','10','10'].map(parseInt) is the classic interview answer: map calls its function with (value, index, array), so the calls are parseInt('10', 0), parseInt('10', 1) and parseInt('10', 2). Radix 0 means "use the default", radix 1 is invalid, radix 2 reads binary. Write xs.map((s) => parseInt(s, 10)) or xs.map(Number).

Answers JS Math 21 in the original set.

22 Quotient and remainder, and dividing by zero

easy

Write divide(a, b) returning { quotient, remainder } with an integer quotient. Then check what happens for b of zero.

Stuck? Three hints, smallest first
  1. 1. JavaScript has no integer division operator; Math.trunc(a / b) is it.
  2. 2. % gives the remainder that matches a truncating division.
  3. 3. Division by zero does not throw: it gives Infinity, -Infinity or NaN.

The answer

const divide = (a, b) => ({ quotient: Math.trunc(a / b), remainder: a % b });

console.log('divide(17, 5)   :', divide(17, 5));
console.log('divide(-17, 5)  :', divide(-17, 5), '<- trunc, so -3 remainder -2');
console.log('rebuild         :', 5 * -3 + -2);
console.log('floor would give:', Math.floor(-17 / 5));
console.log('1 / 0           :', 1 / 0, ' -1 / 0:', -1 / 0, ' 0 / 0:', 0 / 0);
console.log('5 % 0           :', 5 % 0);
console.log('how many 40-seat buses for 145 passengers:', Math.ceil(145 / 40));

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.

9 checks

test('17 into 5 goes 3 times with 2 left over', divide(17, 5), { quotient: 3, remainder: 2 });
test('the two parts rebuild the original', 5 * 3 + 2, 17);
test('a negative dividend truncates towards zero', divide(-17, 5), { quotient: -3, remainder: -2 });
test('and that pair still rebuilds it', 5 * -3 + -2, -17);
test('floor would have given -4 instead', Math.floor(-17 / 5), -4);
test('dividing by zero gives an infinity, it does not throw', [1 / 0 === Infinity, -1 / 0 === -Infinity], [true, true]);
test('but 0 / 0 is NaN', Number.isNaN(0 / 0), true);
test('and any remainder by zero is NaN', Number.isNaN(5 % 0), true);
test('ceil is what "how many containers" needs', Math.ceil(145 / 40), 4);

Math.trunc(a / b) and a % b are a matched pair: they satisfy b * q + r === a for negative operands too, which is what makes the rebuild assertion hold. Swapping in Math.floor breaks that pairing — you would need the floored remainder from exercise 24 to match it.

The silent infinities are the practical hazard. A rate of total / count with count of zero gives Infinity, which then renders as "Infinity%" on a dashboard, or as null once it passes through JSON. Guard the divisor rather than the result.

Answers JS Math 22 in the original set.

23 The sign of % on negatives

fair

Evaluate -7 % 3, 7 % -3 and -7 % -3. Which operand decides the sign? Then show % is not the mathematical modulo.

Stuck? Three hints, smallest first
  1. 1. The result takes the sign of the dividend, the left-hand operand.
  2. 2. It is defined as a - trunc(a / b) * b, so it follows truncating division.
  3. 3. Mathematically, -7 mod 3 is 2. JavaScript gives -1.

The answer

console.log('7 % 3    :', 7 % 3);
console.log('-7 % 3   :', -7 % 3, '<- sign of the dividend');
console.log('7 % -3   :', 7 % -3, '<- divisor sign ignored');
console.log('-7 % -3  :', -7 % -3);
console.log('by definition:', -7 - Math.trunc(-7 / 3) * 3);
console.log('5.5 % 2  :', 5.5 % 2, '<- fractions are allowed');
console.log('wrapping an index backwards:', -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.

9 checks

test('a positive remainder is unsurprising', 7 % 3, 1);
test('the sign comes from the dividend', -7 % 3, -1);
test('not from the divisor', 7 % -3, 1);
test('both negative still gives a negative', -7 % -3, -1);
test('so % is not the mathematical modulo', -7 % 3 === 2, false);
test('it is the remainder of a truncating division', -7 - Math.trunc(-7 / 3) * 3, -1);
test('the operands may be fractional', 5.5 % 2, 1.5);
test('wrapping an index backwards therefore fails', -1 % 5, -1);
test('and a parity check must compare to 0, never to 1', [-7 % 2, 7 % 2], [-1, 1]);

% in JavaScript, C, Java and Go is a remainder operator, not a modulo operator. Python and Ruby give 2 for -7 % 3; JavaScript gives -1. Both are internally consistent — they pair with different definitions of integer division — and neither is wrong, but code ported between them silently changes behaviour.

Two places this bites. Wrapping an index: (i - 1) % n gives -1 at the start of the array rather than the last element. And parity: n % 2 === 1 is false for every negative odd number, so use n % 2 !== 0. Exercise 24 builds the helper that fixes the first.

Answers JS Math 23 in the original set.

24 A true modulo helper

fair

Write mod(n, m) that always takes the sign of m, so mod(-7, 3) is 2. Use it to wrap an array index and to do clock arithmetic.

Stuck? Three hints, smallest first
  1. 1. Take the remainder, add the modulus, take the remainder again.
  2. 2. ((n % m) + m) % m — the second % is what stops a positive input overshooting.
  3. 3. For a 12-hour clock, three hours before one o’clock is mod(1 - 3, 12).

The answer

const mod = (n, m) => ((n % m) + m) % m;

const stops = ['Yaba', 'Surulere', 'Mushin', 'Oshodi', 'Ikeja'];
const step = (i, by) => stops[mod(i + by, stops.length)];

console.log('mod(7, 3)    :', mod(7, 3));
console.log('mod(-7, 3)   :', mod(-7, 3), '<- where % gives', -7 % 3);
console.log('mod(-1, 5)   :', mod(-1, 5));
console.log('clock        : 3 hours before 1 is', mod(1 - 3, 12));
console.log('26 hours on  :', mod(26, 24));
console.log('one stop back from Yaba:', step(0, -1));
console.log('mod(7, -3)   :', mod(7, -3), '<- follows the modulus sign');

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.

9 checks

test('mod agrees with % on positives', mod(7, 3), 1);
test('and gives a non-negative answer for a negative input', mod(-7, 3), 2);
test('where % gave -1', -7 % 3, -1);
test('wrapping an index backwards now lands on the last element', mod(-1, 5), 4);
test('three hours before one o clock is ten', mod(1 - 3, 12), 10);
test('twenty-six hours after midnight is two', mod(26, 24), 2);
test('stepping back from the first stop reaches the last', step(0, -1), 'Ikeja');
test('and forward past the end comes round again', step(4, 1), 'Yaba');
test('a negative modulus gives a negative result, matching the sign of m', mod(7, -3), -2);

The double % is doing exactly one job each. The first reduces the magnitude; adding m makes it non-negative; the second brings a value that was already positive back down, since otherwise mod(7, 3) would return 1 + 3. Leave either out and one of the two directions is wrong.

This is the helper for anything cyclic: carousel indexes, days of the week, clock faces, Caesar ciphers, hue angles. Once it exists, mod replaces every if (i < 0) i += n in the codebase, and the wrap-around edge stops needing its own test.

Answers JS Math 24 in the original set.

25 Divisibility

easy

Write divisibleBy(n, d), then label 1 to 15 as fizz, buzz, fizzbuzz or the number itself. Then check what your helper says about a divisor of zero.

Stuck? Three hints, smallest first
  1. 1. Divisible means the remainder is exactly zero.
  2. 2. Check the combined case first, or 15 is labelled fizz and never fizzbuzz.
  3. 3. n % 0 is NaN, and NaN === 0 is false, so nothing is divisible by zero.

The answer

const divisibleBy = (n, d) => n % d === 0;

const label = (n) =>
  divisibleBy(n, 15) ? 'fizzbuzz'
  : divisibleBy(n, 3) ? 'fizz'
  : divisibleBy(n, 5) ? 'buzz'
  : String(n);

const labels = Array.from({ length: 15 }, (_, i) => label(i + 1));

console.log('15 by 3 and 5 :', divisibleBy(15, 3), divisibleBy(15, 5));
console.log('16 by either  :', divisibleBy(16, 3), divisibleBy(16, 5));
console.log('-9 by 3       :', divisibleBy(-9, 3));
console.log('7 by 0        :', divisibleBy(7, 0), '<- 7 % 0 is', 7 % 0);
console.log('labels        :', labels.join(' '));
console.log('digit-sum rule for 3 on 12345:', 12345 % 3 === 0, (1 + 2 + 3 + 4 + 5) % 3 === 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.

9 checks

test('15 divides by both 3 and 5', [divisibleBy(15, 3), divisibleBy(15, 5)], [true, true]);
test('16 divides by neither', [divisibleBy(16, 3), divisibleBy(16, 5)], [false, false]);
test('a negative divides the same way', divisibleBy(-9, 3), true);
test('everything divides by 1', divisibleBy(7, 1), true);
test('and nothing divides by 0', divisibleBy(7, 0), false);
test('zero is divisible by everything', divisibleBy(0, 7), true);
test('the fifteen labels', labels, ['1', '2', 'fizz', '4', 'buzz', 'fizz', '7', '8', 'fizz', 'buzz', '11', 'fizz', '13', '14', 'fizzbuzz']);
test('the digit-sum rule for 3 agrees with the remainder', [12345 % 3 === 0, (1 + 2 + 3 + 4 + 5) % 3 === 0], [true, true]);
test('and the last-digit rule for 5 does too', [1230 % 5 === 0, 1234 % 5 === 0], [true, false]);

Order of tests is the whole exercise. Checking 3 before 15 means 15 never reaches its own branch, and the bug is invisible until you look at exactly one line of output. Test the most specific condition first — or derive the label by concatenation, which removes the ordering question entirely.

divisibleBy(7, 0) returning false rather than throwing is worth knowing: 7 % 0 is NaN, and NaN === 0 is false, so the helper is accidentally safe. divisibleBy(0, 7) is true, which is mathematically correct and occasionally surprising in a validation.

Answers JS Math 25 in the original set.

26 Even and odd

easy

Write isEven and isOdd that are right for negatives and refuse a fraction. Then show what n & 1 does with 8.5.

Stuck? Three hints, smallest first
  1. 1. n % 2 === 0 is safe for even; the odd case needs care because -7 % 2 is -1.
  2. 2. Insist on Number.isInteger first, or 8.5 will be classified.
  3. 3. & converts to a 32-bit integer first, which truncates the fraction before it looks.

The answer

const isEven = (n) => Number.isInteger(n) && n % 2 === 0;
const isOdd = (n) => Number.isInteger(n) && Math.abs(n % 2) === 1;

console.log('8, 7        :', isEven(8), isEven(7));
console.log('-7 odd?     :', isOdd(-7), 'because -7 % 2 is', -7 % 2);
console.log('0 even?     :', isEven(0));
console.log('8.5         :', isEven(8.5), isOdd(8.5), '<- neither');
console.log('8.5 & 1     :', 8.5 & 1, '<- truncated to 8 first, so it looks even');
console.log('big integer :', (2 ** 40 + 1) & 1, '<- parity survives 32-bit truncation');
console.log('1 to 4      :', [1, 2, 3, 4].map(isEven));

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.

9 checks

test('8 is even and 7 is not', [isEven(8), isEven(7)], [true, false]);
test('-7 is odd, which is why abs is needed', isOdd(-7), true);
test('since -7 % 2 is -1, not 1', -7 % 2, -1);
test('zero is even', isEven(0), true);
test('a fraction is neither even nor odd', [isEven(8.5), isOdd(8.5)], [false, false]);
test('though the bitwise test calls 8.5 even, having truncated it', (8.5 & 1) === 0, true);
test('& 1 does agree on integers, even large ones, because 2 ** 32 is even', (2 ** 40 + 1) & 1, 1);
test('parity alternates down a list', [1, 2, 3, 4].map(isEven), [false, true, false, true]);
test('and exactly half of 1 to 10 are even', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter(isEven).length, 5);

n % 2 === 1 is the bug to remember: it is false for every negative odd number, because the remainder carries the dividend's sign. Either compare against 0 with !==, or take the absolute value as isOdd does here.

n & 1 is the fast idiom and it is not equivalent. It runs ToInt32 on its operand, so 8.5 becomes 8 and reports even, and anything past 231 wraps. Parity happens to survive the wrap — 232 is even, so the low bit is preserved — but & silently accepting a fraction is the reason the explicit Number.isInteger guard is here.

Answers JS Math 26 in the original set.

27 Powers, with ** and Math.pow

easy

Raise numbers to integer, fractional, negative and zero exponents. Then find the exponent at which a power of two overflows to Infinity.

Stuck? Three hints, smallest first
  1. 1. a ** b and Math.pow(a, b) are the same operation.
  2. 2. A fractional exponent is a root, and a negative exponent is a reciprocal.
  3. 3. A double runs out at about 1.8e308, so somewhere near 2 ** 1024 there is a cliff.

The answer

console.log('2 ** 10        :', 2 ** 10, 'and Math.pow:', Math.pow(2, 10));
console.log('27 ** (1/3)    :', 27 ** (1 / 3), '<- a root');
console.log('2 ** -2        :', 2 ** -2, '<- a reciprocal');
console.log('anything ** 0  :', 0 ** 0, 5 ** 0);
console.log('2 ** 3 ** 2    :', 2 ** 3 ** 2, '<- right associative');
console.log('2 ** 1023      :', 2 ** 1023);
console.log('2 ** 1024      :', 2 ** 1024, '<- over the edge');
console.log('(-8) ** (1/3)  :', (-8) ** (1 / 3), '<- no real answer by this route');

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.

9 checks

test('** and Math.pow agree', [2 ** 10, Math.pow(2, 10)], [1024, 1024]);
test('a fractional exponent is a root', 27 ** (1 / 3), 3);
test('a negative exponent is a reciprocal', 2 ** -2, 0.25);
test('anything to the power 0 is 1, including 0', [0 ** 0, 5 ** 0], [1, 1]);
test('** associates to the right', 2 ** 3 ** 2, 512);
test('2 ** 1023 is finite', 2 ** 1023, 8.98846567431158e+307);
test('2 ** 1024 overflows to Infinity', 2 ** 1024 === Infinity, true);
test('a negative base with a fractional exponent is NaN', Number.isNaN((-8) ** (1 / 3)), true);
test('so use Math.cbrt for an odd root of a negative', Math.cbrt(-8), -2);

(-8) ** (1/3) being NaN is not a bug: the operator is defined for a negative base only with an integer exponent, because in general there is no single real answer — the cube root of -8 has three complex solutions and IEEE-754 declines to choose. Math.cbrt exists precisely to give the real one.

0 ** 0 is 1 by specification. Mathematicians argue about it; IEEE-754 and JavaScript do not.

The overflow cliff is silent. 2 ** 1024 is Infinity, and any later arithmetic keeps it, so a compound-growth calculation with a large exponent should be checked with Number.isFinite rather than trusted.

Answers JS Math 27 in the original set.

28 Square roots and cube roots

easy

Take the square root of 144 and of 2, and the cube root of -27. Then show that squaring Math.sqrt(2) does not give back 2.

Stuck? Three hints, smallest first
  1. 1. Math.sqrt for squares, Math.cbrt for cubes.
  2. 2. Math.sqrt of a negative is NaN; Math.cbrt of a negative is fine.
  3. 3. An irrational root cannot be stored exactly, so the round trip lands a hair off.

The answer

console.log('sqrt(144)     :', Math.sqrt(144));
console.log('sqrt(2)       :', Math.sqrt(2));
console.log('squared again :', Math.sqrt(2) ** 2, '<- not 2');
console.log('sqrt(-4)      :', Math.sqrt(-4));
console.log('cbrt(-27)     :', Math.cbrt(-27));
console.log('144 ** 0.5    :', 144 ** 0.5);
console.log('sqrt(0), sqrt(Infinity):', Math.sqrt(0), Math.sqrt(Infinity));

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.

9 checks

test('the root of a perfect square is exact', Math.sqrt(144), 12);
test('the root of 2 is irrational, so it is approximate', Math.sqrt(2), 1.4142135623730951);
test('squaring it does not return 2', Math.sqrt(2) ** 2 === 2, false);
test('it lands a hair over', Math.sqrt(2) ** 2, 2.0000000000000004);
test('the root of a negative is NaN', Number.isNaN(Math.sqrt(-4)), true);
test('but a cube root handles a negative', Math.cbrt(-27), -3);
test('sqrt of 0 is 0, and of Infinity is Infinity', [Math.sqrt(0), Math.sqrt(Infinity) === Infinity], [0, true]);
test('raising to the power 0.5 is the same function', 144 ** 0.5, 12);
test('and Math.hypot is sqrt of a sum of squares', Math.hypot(9, 12), 15);

Math.sqrt(2) ** 2 is the round-trip test worth internalising: irrational roots are stored to 53 bits, so squaring recovers a value one bit away from the original. Never write Math.sqrt(n) ** 2 === n; for a perfect-square test, work in integers as exercise 47 does.

Math.sqrt(-4) gives NaN rather than throwing, which is why a quadratic solver has to check its discriminant before rooting it — exercise 112 does exactly that.

Answers JS Math 28 in the original set.

29 An nth root that handles negatives

fair

Write nthRoot(x, n). It must give -2 for the 5th root of -32, and NaN for the 4th root of -16, since no real number qualifies.

Stuck? Three hints, smallest first
  1. 1. x ** (1 / n) is the base case, and it fails for every negative x.
  2. 2. An odd root of a negative exists: take the root of the magnitude and put the sign back.
  3. 3. An even root of a negative has no real value, so let it stay NaN.

The answer

function nthRoot(x, n) {
  if (x < 0 && Math.abs(n % 2) === 1) return -(Math.abs(x) ** (1 / n));
  return x ** (1 / n);
}

// Roots are rarely exact, so compare after rounding.
const round = (v, places = 10) => Math.round(v * 10 ** places) / 10 ** places;

console.log('nthRoot(27, 3)  :', nthRoot(27, 3));
console.log('nthRoot(81, 4)  :', nthRoot(81, 4));
console.log('nthRoot(-32, 5) :', nthRoot(-32, 5));
console.log('plain expression:', (-32) ** (1 / 5), '<- NaN');
console.log('nthRoot(-16, 4) :', nthRoot(-16, 4), '<- correctly no real answer');
console.log('nthRoot(1000, 3):', nthRoot(1000, 3), 'against Math.cbrt:', Math.cbrt(1000));

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.

9 checks

test('the cube root of 27', nthRoot(27, 3), 3);
test('the 4th root of 81', nthRoot(81, 4), 3);
test('an odd root of a negative is negative', nthRoot(-32, 5), -2);
test('where the plain expression gives NaN', Number.isNaN((-32) ** (1 / 5)), true);
test('an even root of a negative has no real answer', Number.isNaN(nthRoot(-16, 4)), true);
test('the 1st root is the number itself', nthRoot(7, 1), 7);
test('the general form is less accurate than cbrt', nthRoot(1000, 3), 9.999999999999998);
test('so round before comparing', round(nthRoot(1000, 3)), 10);
test('while cbrt gets it exactly', Math.cbrt(1000), 10);

Two things to carry. First, -(Math.abs(x) ** (1 / n)) needs its brackets: -Math.abs(x) ** (1 / n) is a syntax error, because the language refuses to guess whether the unary minus binds before or after **.

Second, the accuracy assertion. 1000 ** (1/3) is 9.999999999999998 because 1/3 is not a third, so the exponent itself is already approximate. Math.cbrt uses a dedicated algorithm and returns exactly 10. Wherever a specific root has its own function — sqrt, cbrt, hypot, log2, log10 — that function is more accurate than building it from ** or log.

Answers JS Math 29 in the original set.

30 Math.hypot, and why not to square first

fair

Find the length of the vector (3, 4) and of (2, 3, 6). Then find a pair of inputs where Math.sqrt(a * a + b * b) gives Infinity and Math.hypot does not.

Stuck? Three hints, smallest first
  1. 1. Math.hypot takes any number of arguments and returns the square root of the sum of their squares.
  2. 2. Squaring 3e200 overflows a double, even though the answer would fit comfortably.
  3. 3. hypot scales its inputs internally before squaring, which is exactly what that buys you.

The answer

const manual = (a, b) => Math.sqrt(a * a + b * b);

console.log('hypot(3, 4)      :', Math.hypot(3, 4));
console.log('hypot(2, 3, 6)   :', Math.hypot(2, 3, 6));
console.log('manual(3, 4)     :', manual(3, 4));
console.log('3e200 squared    :', 3e200 * 3e200, '<- overflowed');
console.log('manual(3e200, 4e200):', manual(3e200, 4e200));
console.log('hypot(3e200,4e200)  :', Math.hypot(3e200, 4e200), '<- should be 5e200');
console.log('hypot()          :', Math.hypot());
console.log('with an Infinity :', Math.hypot(Infinity, NaN));

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.

9 checks

test('the 3-4-5 triangle', Math.hypot(3, 4), 5);
test('it takes any number of arguments', Math.hypot(2, 3, 6), 7);
test('the manual version agrees at ordinary magnitudes', manual(3, 4), 5);
test('but squaring 3e200 overflows a double', 3e200 * 3e200 === Infinity, true);
test('so the manual version returns Infinity', manual(3e200, 4e200) === Infinity, true);
test('while hypot survives, to within a rounding', Math.hypot(3e200, 4e200), 4.9999999999999995e+200);
test('which is 5e200 to fifteen digits', Math.abs(Math.hypot(3e200, 4e200) / 5e200 - 1) < 1e-15, true);
test('hypot of no arguments is 0', Math.hypot(), 0);
test('and an Infinity wins even against a NaN', Math.hypot(Infinity, NaN) === Infinity, true);

Math.hypot is not sugar. It divides by the largest magnitude first, squares the scaled values, then multiplies back — so intermediate overflow and underflow are avoided for inputs the answer could never have overflowed on. The 3e200 pair is the demonstration: the true answer 5e200 is well inside range, and only the naive intermediate a * a was not.

Two footnotes. hypot is slower than the manual form, so in a tight inner loop over screen coordinates the manual version is the right call. And the last assertion follows the specification: an infinite argument dominates, even when another argument is NaN.

Answers JS Math 30 in the original set.

31 Primality by trial division, and why the root is far enough

fair

Write isPrime(n) that stops testing divisors at the square root of n. Then count the divisions it performs for 9973, and compare that with testing every divisor up to n - 1.

Stuck? Three hints, smallest first
  1. 1. Deal with n < 2 and with 2 itself first, then test only odd divisors.
  2. 2. If n = a * b with a <= b, then a cannot be larger than the root — so a divisor above the root always has a partner below it.
  3. 3. Write the loop condition as d * d <= n, not d <= Math.sqrt(n): it stays in integers and computes no roots.

The answer

let divisions = 0;                 // a counter, so the saving is measurable

function isPrime(n) {
  if (!Number.isInteger(n) || n < 2) return false;
  if (n % 2 === 0) return n === 2;                 // 2 is prime, other evens are not
  for (let d = 3; d * d <= n; d += 2) {
    divisions++;
    if (n % d === 0) return false;
  }
  return true;
}

// The same question answered without the shortcut: every divisor up to n - 1.
const workIfNaive = (n) => {
  let c = 0;
  for (let d = 2; d < n; d++) {
    c++;
    if (n % d === 0) break;
  }
  return c;
};

const primesBelow30 = [];
for (let n = 0; n < 30; n++) if (isPrime(n)) primesBelow30.push(n);

divisions = 0;
const prime9973 = isPrime(9973);
const workToRoot = divisions;

divisions = 0;
const composite9409 = isPrime(9409);      // 97 * 97, so the only divisor sits on the root
const workTo9409 = divisions;

const oddsBelowRoot = (Math.floor(Math.sqrt(9973)) - 3) / 2 + 1;

console.log('primes below 30 :', primesBelow30);
console.log('9973 is prime   :', prime9973, 'after', workToRoot, 'divisions');
console.log('sqrt(9973)      :', Math.sqrt(9973));
console.log('9409 is prime   :', composite9409, 'settled after', workTo9409, 'divisions');
console.log('to n - 1 instead:', workIfNaive(9973), 'divisions');

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.

9 checks

test('the primes below 30', primesBelow30, [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]);
test('0, 1, a negative and a fraction are not prime', [0, 1, -7, 2.5].map(isPrime), [false, false, false, false]);
test('2 is, and it is the only even one', [isPrime(2), isPrime(4)], [true, false]);
test('7919, the thousandth prime', isPrime(7919), true);
test('9409 is 97 squared, so it is composite', [composite9409, 9409 % 97], [false, 0]);
test('and it took 48 divisions to notice, one per odd number up to 97', workTo9409, 48);
test('a prime uses the whole range: one per odd number below its root', [workToRoot, oddsBelowRoot], [49, 49]);
test('testing every divisor up to n - 1 costs 9971 instead', workIfNaive(9973), 9971);
test('so stopping at the root cut the work by more than 200 times', workIfNaive(9973) / workToRoot > 200, true);

The root bound is not a heuristic, it is a proof. Divisors come in pairs whose product is n, and in every pair one member is at most sqrt(n). So if nothing at or below the root divides n, nothing above it can either. That turns a linear scan into a sqrt(n) one: 49 divisions instead of 9971 for a five-figure number, and the gap widens with every digit.

Two details worth copying. d * d <= n beats d <= Math.sqrt(n) because it never leaves the integers, so there is no rounding to reason about — and 9409 is the case that would catch you, since its only divisor is the root and a root computed slightly low would skip it. And the Number.isInteger guard matters: without it isPrime(2.5) would run a loop that never divides evenly and confidently report a fractional prime.

Answers JS Math 31 in the original set.

32 The sieve of Eratosthenes

fair

List every prime up to a limit by striking out multiples instead of testing each candidate. Start each prime's marking at p * p, and work out which primes are ever used as markers for a limit of 50.

Stuck? Three hints, smallest first
  1. 1. A Uint8Array of flags, one per number, is the whole data structure.
  2. 2. The outer loop only has to reach p * p <= limit; past that every composite has already been struck out.
  3. 3. Marking can start at p * p because 2p, 3p, ... (p-1)p were each already struck out by a smaller prime.

The answer

const markers = [];                  // which primes actually did any striking out

function sieve(limit) {
  if (limit < 2) return [];
  const composite = new Uint8Array(limit + 1);
  for (let p = 2; p * p <= limit; p++) {
    if (composite[p]) continue;        // p was struck out, so it is not prime
    if (limit === 50) markers.push(p);
    for (let m = p * p; m <= limit; m += p) composite[m] = 1;
  }
  const out = [];
  for (let n = 2; n <= limit; n++) if (!composite[n]) out.push(n);
  return out;
}

const isPrime = (n) => {
  if (n < 2) return false;
  if (n % 2 === 0) return n === 2;
  for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
  return true;
};

const to50 = sieve(50);
const to100000 = sieve(100000);
const counts = [100, 1000, 10000].map((n) => sieve(n).length);

console.log('primes to 50    :', to50);
console.log('markers used    :', markers, '<- nothing above sqrt(50)');
console.log('counts          :', counts);
console.log('primes below 1e5:', to100000.length, 'largest:', to100000.at(-1));

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.

9 checks

test('every prime up to 50', to50, [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]);
test('the marking loop only ever used 2, 3, 5 and 7', markers, [2, 3, 5, 7]);
test('a limit below 2 has no primes at all', [sieve(0), sieve(1), sieve(2)], [[], [], [2]]);
test('25 primes below 100, 168 below 1000, 1229 below 10000', counts, [25, 168, 1229]);
test('the 25th prime is 97', sieve(100).at(-1), 97);
test('they sum to 1060', sieve(100).reduce((a, b) => a + b, 0), 1060);
test('9592 primes below 100000, from one pass over the flags', to100000.length, 9592);
test('the largest of them is 99991', to100000.at(-1), 99991);
test('and the sieve agrees with trial division below 200', same(sieve(200), sieve(200).filter(isPrime)), true);

The sieve trades memory for time: one byte per candidate buys you every prime in the range for roughly n log log n work, where testing each number separately costs n sqrt(n). The limit here stops at a hundred thousand only because these panels run under an iteration guard; the algorithm itself scales to a million on the same few lines, needing a megabyte of flags to do it.

The two loop bounds are the exercise. The outer loop stops at p * p > limit because any composite left standing would need a factor above its own root, which exercise 31 rules out — that is why nothing above 7 marks anything for a limit of 50. The inner loop starts at p * p rather than 2p because every earlier multiple carries a smaller prime factor and was struck out already. Starting at 2p is not wrong, just wasteful.

Answers JS Math 32 in the original set.

33 The nth prime

fair

Return the nth prime: the 1st is 2, the 6th is 13, the 1000th is 7919. Then get the 10001st a second way, by sieving a range you have argued is big enough.

Stuck? Three hints, smallest first
  1. 1. Counting upwards and testing each candidate needs no size estimate, only patience.
  2. 2. To sieve instead you need an upper bound on the nth prime. For n >= 6, n * (ln n + ln ln n) is one.
  3. 3. Take the n - 1 index of the sieved list, and assert the bound rather than trusting it.

The answer

function nthPrimeByCounting(n) {
  if (!Number.isInteger(n) || n < 1) return NaN;
  const isPrime = (m) => {
    if (m < 2) return false;
    if (m % 2 === 0) return m === 2;
    for (let d = 3; d * d <= m; d += 2) if (m % d === 0) return false;
    return true;
  };
  let seen = 0;
  for (let c = 2; ; c++) if (isPrime(c) && ++seen === n) return c;
}

// Rosser and Schoenfeld: p(n) < n * (ln n + ln ln n) for n >= 6.
const bound = (n) => (n < 6 ? 15 : Math.ceil(n * (Math.log(n) + Math.log(Math.log(n)))));

function nthPrimeBySieve(n) {
  const limit = bound(n);
  const composite = new Uint8Array(limit + 1);
  let seen = 0;
  for (let p = 2; p <= limit; p++) {
    if (composite[p]) continue;
    if (++seen === n) return p;
    for (let m = p * p; m <= limit; m += p) composite[m] = 1;
  }
  return NaN;                          // the bound was too small
}

const firstTen = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(nthPrimeByCounting);
const counted1000 = nthPrimeByCounting(1000);      // counted once, reused below
const sieved10001 = nthPrimeBySieve(10001);

console.log('first ten     :', firstTen);
console.log('1000th        :', counted1000, 'and by sieve:', nthPrimeBySieve(1000));
console.log('10001st       :', sieved10001, 'from a sieve of', bound(10001));
console.log('nthPrime(0)   :', nthPrimeByCounting(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.

9 checks

test('the first prime is 2, and the 6th is 13', [firstTen[0], firstTen[5]], [2, 13]);
test('the first ten', firstTen, [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]);
test('the 25th is 97, the 100th is 541', [nthPrimeByCounting(25), nthPrimeByCounting(100)], [97, 541]);
test('the 1000th is 7919', counted1000, 7919);
test('and both methods agree on it', nthPrimeBySieve(1000), counted1000);
test('the sieve reaches the 10001st: 104743', sieved10001, 104743);
test('and the bound it allocated for really was large enough', bound(10001) > 104743, true);
test('the bound holds all the way down to n = 6', [6, 7, 25, 100].every((n) => bound(n) > nthPrimeByCounting(n)), true);
test('a zero or fractional position has no answer', [Number.isNaN(nthPrimeByCounting(0)), Number.isNaN(nthPrimeByCounting(2.5))], [true, true]);

Counting upwards needs no estimate and is the version to write when you want one prime. Sieving needs a limit before it can allocate, which is the interesting constraint: you cannot sieve for "the 10001st prime" without first bounding where it lives. n (ln n + ln ln n) is a proven bound for n >= 6, and the check above holds it to that rather than taking it on trust.

Note the NaN return when the bound turns out too small. A sieve-based nth prime that silently returns undefined in that case is the kind of bug that only shows up on the input you did not test — and undefined flowing into later arithmetic produces NaN anyway, just further from the cause.

Answers JS Math 33 in the original set.

34 Prime factorisation

fair

Break a number into its prime factors with multiplicity, so 360 gives [2, 2, 2, 3, 3, 5]. Then present the same thing as exponents, and factorise Number.MAX_SAFE_INTEGER.

Stuck? Three hints, smallest first
  1. 1. Divide out each factor completely before moving on, so the number itself shrinks as you go.
  2. 2. The loop bound d * d <= n is checked against the shrinking n, which is why this is fast on numbers with a large prime factor.
  3. 3. Whatever is left above 1 when the loop ends is itself prime, so push it.

The answer

function factorise(n) {
  const out = [];
  for (let d = 2; d * d <= n; d++) {
    while (n % d === 0) {
      out.push(d);
      n /= d;                          // n shrinks, so the bound tightens as we go
    }
  }
  if (n > 1) out.push(n);              // the last cofactor is prime
  return out;
}

// The same information, one entry per distinct prime.
const exponents = (n) => {
  const groups = [];
  for (const p of factorise(n)) {
    const last = groups.at(-1);
    if (last && last[0] === p) last[1]++;
    else groups.push([p, 1]);
  }
  return groups;
};

const divisorCount = (n) => exponents(n).reduce((acc, [, e]) => acc * (e + 1), 1);

console.log('360        :', factorise(360));
console.log('as powers  :', exponents(360));
console.log('1000000    :', exponents(1000000));
console.log('MAX_SAFE   :', factorise(Number.MAX_SAFE_INTEGER));
console.log('a big one  :', factorise(600851475143));

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.

9 checks

test('360 is 2 cubed times 3 squared times 5', factorise(360), [2, 2, 2, 3, 3, 5]);
test('in exponent form', exponents(360), [[2, 3], [3, 2], [5, 1]]);
test('the factors multiply back to the number', factorise(360).reduce((a, b) => a * b, 1), 360);
test('1 has no prime factors, and a prime has exactly itself', [factorise(1), factorise(97)], [[], [97]]);
test('a million is 2 to the 6th times 5 to the 6th', exponents(1000000), [[2, 6], [5, 6]]);
test('360 therefore has 24 divisors, from (3+1)(2+1)(1+1)', divisorCount(360), 24);
test('600851475143 splits into four primes', factorise(600851475143), [71, 839, 1471, 6857]);
test('and MAX_SAFE_INTEGER into three', factorise(Number.MAX_SAFE_INTEGER), [6361, 69431, 20394401]);
test('and those three multiply back to it exactly', 6361 * 69431 * 20394401 === Number.MAX_SAFE_INTEGER, true);

The shrinking n is what makes this practical. Factorising 9007199254740991 looks like it should test divisors up to 94,906,265 — its root — but after 6361 and 69431 are divided out the remaining cofactor is 20394401, whose own root is under 4600, and the loop bound follows the value down. Only a number that is prime, or the product of two primes of similar size, actually costs the full sqrt(n).

The trailing if (n > 1) is not a tidy-up, it is the whole reason the loop can stop at the root. Once no divisor at or below sqrt(n) remains, what is left cannot be composite, so it is emitted directly rather than searched for.

Answers JS Math 34 in the original set.

35 Every divisor, found in pairs

fair

List all divisors of a number in ascending order, looping only as far as the square root. Then find which numbers have an odd number of divisors, and say why.

Stuck? Three hints, smallest first
  1. 1. Each d that divides n hands you a second divisor for free: n / d.
  2. 2. Collect the small ones going up and the partners going down, then join them.
  3. 3. When d === n / d the pair is a single number, so push it once.

The answer

let iterations = 0;

function divisors(n) {
  if (!Number.isInteger(n) || n < 1) return [];
  const small = [];
  const large = [];
  for (let d = 1; d * d <= n; d++) {
    iterations++;
    if (n % d !== 0) continue;
    small.push(d);
    if (d !== n / d) large.push(n / d);      // skip the partner of a square root
  }
  return [...small, ...large.reverse()];
}

const oddCount = [];
for (let n = 1; n <= 50; n++) if (divisors(n).length % 2 === 1) oddCount.push(n);

iterations = 0;
const ofMillion = divisors(1000000);
const workForMillion = iterations;

console.log('divisors of 36 :', divisors(36));
console.log('of 1 and of 13 :', divisors(1), divisors(13));
console.log('odd count up to 50:', oddCount);
console.log('1e6 has', ofMillion.length, 'divisors, found in', workForMillion, 'iterations');
console.log('sum for 36     :', divisors(36).reduce((a, b) => a + b, 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.

9 checks

test('the divisors of 36, ascending', divisors(36), [1, 2, 3, 4, 6, 9, 12, 18, 36]);
test('1 divides only itself, and a prime has two divisors', [divisors(1), divisors(13)], [[1], [1, 13]]);
test('they come out sorted without a sort call', same(divisors(360), divisors(360).slice().sort((a, b) => a - b)), true);
test('360 has 24 of them, matching the exponent formula', divisors(360).length, 24);
test('only perfect squares have an odd number', oddCount, [1, 4, 9, 16, 25, 36, 49]);
test('because the root is its own partner', divisors(49), [1, 7, 49]);
test('a million has 49 divisors', ofMillion.length, 49);
test('found in 1000 iterations rather than a million', workForMillion, 1000);
test('and they pair up to the number itself', ofMillion.map((d, i) => d * ofMillion[ofMillion.length - 1 - i]).every((p) => p === 1000000), true);

The pairing is the idea, and it explains the odd-count result: divisors arrive two at a time, d with n / d, so the total is even — unless some d equals its own partner, which happens exactly when n is a perfect square. That is the cheapest perfect-square test there is, and exercise 47 uses the arithmetic version of it.

Watch the ordering. The small divisors come out ascending and their partners come out descending, so reversing the second list and concatenating gives sorted output for free. Pushing n / d as you go and sorting at the end works too, and costs a comparison sort for information you already had.

Answers JS Math 35 in the original set.

36 Proper divisors: perfect, abundant and deficient

fair

Sum the divisors of n excluding n itself, then classify the number as perfect, abundant or deficient. Find every perfect number below 10000, and the smallest odd abundant number.

Stuck? Three hints, smallest first
  1. 1. Proper divisors are all the divisors except n. Note that 1 has none.
  2. 2. Perfect means the sum equals n, abundant means it exceeds n, deficient means it falls short.
  3. 3. A prime has exactly one proper divisor, so every prime is deficient.

The answer

function divisors(n) {
  const small = [];
  const large = [];
  for (let d = 1; d * d <= n; d++) {
    if (n % d !== 0) continue;
    small.push(d);
    if (d !== n / d) large.push(n / d);
  }
  return [...small, ...large.reverse()];
}

const properDivisors = (n) => divisors(n).filter((d) => d !== n);
const aliquot = (n) => properDivisors(n).reduce((a, b) => a + b, 0);

function classify(n) {
  const sum = aliquot(n);
  if (sum === n) return 'perfect';
  return sum > n ? 'abundant' : 'deficient';
}

const perfect = [];
for (let n = 1; n < 10000; n++) if (classify(n) === 'perfect') perfect.push(n);

const abundantBelow100 = [];
for (let n = 1; n < 100; n++) if (classify(n) === 'abundant') abundantBelow100.push(n);

let firstOddAbundant = 0;
for (let n = 1; !firstOddAbundant; n += 2) if (classify(n) === 'abundant') firstOddAbundant = n;

console.log('proper divisors of 28 :', properDivisors(28), 'summing to', aliquot(28));
console.log('perfect below 10000   :', perfect);
console.log('abundant below 100    :', abundantBelow100.length, 'of them, starting', abundantBelow100.slice(0, 4));
console.log('smallest odd abundant :', firstOddAbundant);
console.log('220 and 284           :', aliquot(220), aliquot(284), '<- each other');

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.

9 checks

test('28 is the sum of its proper divisors', [properDivisors(28), aliquot(28)], [[1, 2, 4, 7, 14], 28]);
test('so it is perfect, while 12 overshoots and 8 falls short', [12, 8, 28].map(classify), ['abundant', 'deficient', 'perfect']);
test('four perfect numbers below 10000', perfect, [6, 28, 496, 8128]);
test('1 has no proper divisors at all, so it is deficient', [aliquot(1), classify(1)], [0, 'deficient']);
test('every prime is deficient, with an aliquot sum of 1', [2, 3, 5, 97, 7919].every((p) => aliquot(p) === 1), true);
test('21 abundant numbers below 100, the first being 12', [abundantBelow100.length, abundantBelow100[0]], [21, 12]);
test('but the smallest odd one is 945', firstOddAbundant, 945);
test('220 and 284 sum to each other rather than to themselves', [aliquot(220), aliquot(284)], [284, 220]);
test('which makes them amicable, not perfect', classify(220), 'abundant');

Only four perfect numbers exist below ten thousand, and the fifth is 33,550,336 — they thin out fast, and every known one is even. Abundant numbers, by contrast, are common among the evens: 21 of the 99 numbers below 100, every one of them even, because the smallest odd abundant number is 945. That asymmetry is worth an assertion, because "abundant numbers are everywhere" and "odd abundant numbers are rare" are both true and easily confused.

Two edge cases the naive version gets wrong. aliquot(1) is 0, not 1, because 1's only divisor is itself and self is excluded. And filter(d => d !== n) is the right exclusion — dropping the last element is equivalent here only because the list is sorted, which is a fact about the previous exercise rather than about proper divisors.

Answers JS Math 36 in the original set.

37 The greatest common divisor, by Euclid

fair

Write gcd(a, b) with Euclid's algorithm. Count its steps against the subtraction version on gcd(1, 1000000), and find the input shape that is worst for the remainder version.

Stuck? Three hints, smallest first
  1. 1. The remainder version replaces (a, b) with (b, a % b) until b is 0.
  2. 2. The subtraction version repeatedly takes the smaller from the larger, which is the same idea one unit at a time.
  3. 3. Consecutive Fibonacci numbers make every remainder step remove exactly one term, which is the slowest the remainder version can go.

The answer

function gcd(a, b) {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b) [a, b] = [b, a % b];
  return a;
}

const gcdSteps = (a, b) => {
  let n = 0;
  a = Math.abs(a);
  b = Math.abs(b);
  while (b) {
    [a, b] = [b, a % b];
    n++;
  }
  return n;
};

const subtractionSteps = (a, b) => {
  let n = 0;
  while (a !== b) {
    if (a > b) a -= b;
    else b -= a;
    n++;
  }
  return n;
};

const fib = [1, 1];
while (fib.length < 14) fib.push(fib.at(-1) + fib.at(-2));
const fibStepCounts = fib.slice(0, 12).map((f, i) => gcdSteps(f, fib[i + 1]));

// The worst pair for subtraction: one small argument, one large.
const bySubtraction = subtractionSteps(1, 100000);
const byRemainder = gcdSteps(1, 100000);

console.log('gcd(48, 18)      :', gcd(48, 18));
console.log('gcd(1071, 462)   :', gcd(1071, 462), 'in', gcdSteps(1071, 462), 'remainder steps');
console.log('the same by subtraction:', subtractionSteps(1071, 462), 'steps');
console.log('gcd(1, 100000)   :', byRemainder, 'remainder steps against', bySubtraction, 'subtractions');
console.log('consecutive Fibonacci pairs cost:', fibStepCounts);

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.

9 checks

test('gcd(48, 18) is 6', gcd(48, 18), 6);
test('gcd(1071, 462) is 21, in three remainder steps', [gcd(1071, 462), gcdSteps(1071, 462)], [21, 3]);
test('subtraction needs eleven for the same pair', subtractionSteps(1071, 462), 11);
test('and 99999 for gcd(1, 100000), where remainders need 2', [bySubtraction, byRemainder], [99999, 2]);
test('zero is absorbing: gcd(n, 0) is n, and gcd(0, 0) is 0', [gcd(5, 0), gcd(0, 5), gcd(0, 0)], [5, 5, 0]);
test('signs are stripped, so the result is never negative', [gcd(-8, 12), gcd(-8, -12)], [4, 4]);
test('coprime numbers share only 1', [gcd(17, 5), gcd(9, 28)], [1, 1]);
test('the answer divides both inputs, and nothing larger does', [270 % gcd(270, 192), 192 % gcd(270, 192), gcd(270, 192)], [0, 0, 6]);
test('consecutive Fibonacci numbers cost one step per term: the worst case', fibStepCounts, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);

The subtraction form is the one people reinvent, and gcd(1, 100000) shows what it costs: 99,999 passes to discover an answer the remainder form reaches in two, because a % b does every one of those subtractions at once. Worse, the subtraction loop never terminates if either argument is 0 — while (a !== b) spins forever on (0, 5), which is why the version above is only used for counting.

The Fibonacci result is the theoretical worst case made concrete. Each remainder step on (F(k), F(k+1)) yields a quotient of exactly 1, so it removes one term and no more, and the step count grows linearly with the index while the numbers grow exponentially. That is where the bound of about log of the smaller input comes from: no pair does worse.

Answers JS Math 37 in the original set.

38 The least common multiple, from the GCD

fair

Write lcm(a, b) using the GCD. Then find a pair of ordinary-looking integers where a * b / gcd returns a value that is not even an integer, and a / gcd * b is exactly right.

Stuck? Three hints, smallest first
  1. 1. gcd(a, b) * lcm(a, b) === a * b, which is where the formula comes from.
  2. 2. Divide before you multiply: a / gcd is exact, and dividing first keeps the intermediate small.
  3. 3. Two numbers around 3e7 and 2.8e8 have a product past 2**53, where doubles stop counting in ones.

The answer

const gcd = (a, b) => {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b) [a, b] = [b, a % b];
  return a;
};

const lcmNaive = (a, b) => (a * b) / gcd(a, b);        // multiplies first
const lcm = (a, b) => (a / gcd(a, b)) * b;            // divides first

// Both share the factor 12345679, so the true LCM is small; only a * b is huge.
const a = 37037037;
const b = 283950617;

console.log('lcm(4, 6)       :', lcm(4, 6));
console.log('gcd * lcm       :', gcd(12, 18) * lcm(12, 18), 'and a * b:', 12 * 18);
console.log('gcd(a, b)       :', gcd(a, b));
console.log('a * b           :', a * b, '<- past 2**53, so already approximate');
console.log('multiply first  :', lcmNaive(a, b));
console.log('divide first    :', lcm(a, b));

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.

9 checks

test('lcm(4, 6) is 12, and lcm(21, 6) is 42', [lcm(4, 6), lcm(21, 6)], [12, 42]);
test('for coprime inputs it is just the product', lcm(9, 28), 252);
test('gcd times lcm is the product of the inputs', gcd(12, 18) * lcm(12, 18), 12 * 18);
test('lcm(n, n) is n, and lcm with 0 is 0', [lcm(7, 7), lcm(0, 5)], [7, 0]);
test('a and b share the factor 12345679', gcd(a, b), 12345679);
test('their product is not a safe integer', Number.isSafeInteger(a * b), false);
test('so multiplying first returns something that is not even whole', [lcmNaive(a, b), Number.isInteger(lcmNaive(a, b))], [851851850.9999999, false]);
test('while dividing first is exact', lcm(a, b), 851851851);
test('and the true LCM was a safe integer the whole time', Number.isSafeInteger(851851851), true);

The formula is not in question; the order of operations is. a * b here is 1.05e16, past 2 ** 53, so the product is rounded before the division ever happens and the result comes back as 851851850.9999999 — a least common multiple with a fractional part. a / gcd(a, b) is exact because the GCD divides a by definition, so dividing first keeps every intermediate inside the range where doubles count in ones.

This generalises past LCM. Whenever a formula multiplies then divides by something you know is a factor, reorder it. The combinatorics in exercise 55 is the same trick applied to n!, where the naive order overflows to Infinity instead of merely rounding.

Answers JS Math 38 in the original set.

39 GCD and LCM of a whole list

fair

Extend both operations to an array with reduce. Pick the right starting value for each, then find the smallest number divisible by every integer from 1 to 20.

Stuck? Three hints, smallest first
  1. 1. Both operations are associative, so folding pairwise across the list is valid.
  2. 2. gcd(0, n) is n, so 0 is the identity to start a GCD fold with. For LCM it is 1.
  3. 3. Fold the LCM with the divide-first form, or the running value will drift once it passes 2**53.

The answer

const gcd = (a, b) => {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b) [a, b] = [b, a % b];
  return a;
};
const lcm = (a, b) => (a === 0 || b === 0 ? 0 : (a / gcd(a, b)) * b);

const gcdAll = (ns) => ns.reduce(gcd, 0);        // 0 is the identity for gcd
const lcmAll = (ns) => ns.reduce(lcm, 1);        // 1 is the identity for lcm

const upTo = (n) => Array.from({ length: n }, (_, i) => i + 1);

console.log('gcd of 12, 18, 24 :', gcdAll([12, 18, 24]));
console.log('lcm of 2, 3, 4, 5 :', lcmAll([2, 3, 4, 5]));
console.log('empty list        :', gcdAll([]), lcmAll([]));
console.log('lcm of 1..10      :', lcmAll(upTo(10)));
console.log('lcm of 1..20      :', lcmAll(upTo(20)));
console.log('lcm of 1..40      :', lcmAll(upTo(40)), 'safe?', Number.isSafeInteger(lcmAll(upTo(40))));

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.

9 checks

test('the GCD of a list', gcdAll([12, 18, 24]), 6);
test('and its LCM', lcmAll([2, 3, 4, 5]), 60);
test('the identities make the empty list behave', [gcdAll([]), lcmAll([])], [0, 1]);
test('a single element folds to itself', [gcdAll([7]), lcmAll([7])], [7, 7]);
test('zeros are ignored by the GCD fold', gcdAll([0, 0, 5]), 5);
test('the numbers 1 to 10 all divide 2520', [lcmAll(upTo(10)), upTo(10).every((n) => 2520 % n === 0)], [2520, true]);
test('for 1 to 20 it is 232792560', lcmAll(upTo(20)), 232792560);
test('and half of it fails, so nothing smaller could work', upTo(20).some((n) => (232792560 / 2) % n !== 0), true);
test('1 to 40 still lands on a safe integer', [lcmAll(upTo(40)), Number.isSafeInteger(lcmAll(upTo(40)))], [5342931457063200, true]);

The identity value is the part that is easy to get wrong. Starting a GCD fold at 1 gives 1 for every list, since gcd(1, anything) is 1; starting at 0 works because gcd(0, n) is n. The LCM fold is the mirror image: 1 is the identity and 0 would swallow the list.

The LCM of 1 to 20 is 232,792,560, and it grows quickly — by 1 to 40 it is 5.34e15, still under Number.MAX_SAFE_INTEGER but not by much. Push the range to 45 and the fold silently leaves exact-integer territory, which is the point at which this problem belongs in BigInt rather than in doubles.

Answers JS Math 39 in the original set.

40 Simplifying a fraction

fair

Reduce numerator / denominator to lowest terms, keeping any minus sign on the numerator. Then add 1/10 and 2/10 as fractions and compare the result with the same sum in floating point.

Stuck? Three hints, smallest first
  1. 1. Divide both parts by their GCD.
  2. 2. Normalise the sign: if the denominator is negative, flip both.
  3. 3. A zero numerator reduces to 0/1, and a zero denominator is not a fraction at all.

The answer

const gcd = (a, b) => {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b) [a, b] = [b, a % b];
  return a;
};

function simplify(numerator, denominator) {
  if (denominator === 0) return null;                    // not a fraction
  const g = gcd(numerator, denominator) || 1;            // gcd(0, 0) would be 0
  const sign = denominator < 0 ? -1 : 1;
  return [(sign * numerator) / g, (sign * denominator) / g];
}

// a/b + c/d = (ad + cb) / bd, then reduce.
const addFractions = (a, b, c, d) => simplify(a * d + c * b, b * d);

console.log('6/8      ->', simplify(6, 8));
console.log('3/-9     ->', simplify(3, -9));
console.log('-4/-8    ->', simplify(-4, -8));
console.log('0/5      ->', simplify(0, 5));
console.log('5/0      ->', simplify(5, 0));
console.log('1/10 + 2/10 as a fraction:', addFractions(1, 10, 2, 10));
console.log('the same sum in doubles  :', 0.1 + 0.2);

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.

9 checks

test('6/8 reduces to 3/4', simplify(6, 8), [3, 4]);
test('a negative denominator moves its sign to the top', simplify(3, -9), [-1, 3]);
test('two negatives cancel', simplify(-4, -8), [1, 2]);
test('zero over anything is 0/1', simplify(0, 5), [0, 1]);
test('and anything over zero is not a fraction', simplify(5, 0), null);
test('an already-reduced fraction is returned unchanged', [simplify(7, 9), simplify(7, 7)], [[7, 9], [1, 1]]);
test('1/10 + 2/10 is exactly 3/10 in fractions', addFractions(1, 10, 2, 10), [3, 10]);
test('while the same sum in doubles is not 0.3', 0.1 + 0.2, 0.30000000000000004);
test('3/4 + 5/6 is 19/12, which no decimal can hold exactly', addFractions(3, 4, 5, 6), [19, 12]);

Two integers and a GCD give you exact rational arithmetic, which is the standard escape from the problem exercise 11 opened: 0.1 + 0.2 is not 0.3 because tenths are not representable in binary, but [1, 10] and [2, 10] add to [3, 10] with no error at all. Every money and measurement library that refuses to round is doing some version of this.

Three details. gcd(0, 0) is 0, so the || 1 stops a division by zero on simplify(0, 0). The sign has to be normalised deliberately, or [1, -3] and [-1, 3] both exist and compare unequal despite being the same number. And returning null for a zero denominator is a choice — 5/0 in doubles is Infinity, which is arguably worse, because it flows onward without complaint.

Answers JS Math 40 in the original set.

The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.