Math and Numbers

Mental model: Every JavaScript number is a 64-bit float pretending to be a decimal. Round on the way out, never on the way in, and count money in whole pennies.

Level: intermediate · about 15 minutes

console.log(Math.round(2.5), Math.round(-2.5)); // 3 -2
console.log(Math.floor(2.7), Math.floor(-2.7)); // 2 -3
console.log(Math.ceil(2.1),  Math.ceil(-2.1));  // 3 -2
console.log(Math.trunc(2.9), Math.trunc(-2.9)); // 2 -2

console.log(Math.round(-0.5)); // -0, a negative zero

Four ways to remove the decimals, and they disagree the moment the number goes negative.

The four rounding functions are not four flavours of the same idea. floor always moves left on the number line, ceil always moves right, trunc always moves towards zero, and round moves to the nearest integer with ties going up (towards positive infinity). For positive numbers floor and trunc look identical, which is why the difference only bites you when a value can be negative.

Inputroundfloorceiltrunc
2.53232
2.42232
-2.4-2-3-2-2
-2.5-2-3-2-2
-0.4-0-1-0-0
console.log(Math.round(-2.5), Math.trunc(-2.5), Math.floor(-2.5));

round goes to the nearest integer and breaks the tie upwards, giving -2. trunc chops the fraction off, also -2. floor is the only one that goes down, giving -3. If you have been using floor as "remove the decimals", this is the bug waiting for you.

The rest of the Math toolbox

console.log(Math.abs(-7), Math.sign(-7), Math.sign(0)); // 7 -1 0
console.log(Math.min(3, 1, 2), Math.max(3, 1, 2));     // 1 3
console.log(Math.max(...[3, 1, 2]));                   // 3, spread an array
console.log(2 ** 10, Math.sqrt(81), Math.cbrt(27));    // 1024 9 3
console.log(Math.hypot(3, 4));                         // 5, no manual squaring
console.log(Math.log2(1024), Math.log10(1000));        // 10 3

Random, and random you can reproduce

// A float in [0, 1). Never exactly 1.
console.log(Math.random() < 1); // true, always

// An integer from min to max, both ends included.
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
console.log(randInt(1, 6)); // 1..6

// Pick an element.
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
console.log(pick(['rock', 'paper', 'scissors']));

The two formulas worth memorising.

function mulberry32(seed) {
  return function next() {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const a = mulberry32(42);
const b = mulberry32(42);
console.log(a() === b(), a() === b()); // true true, identical streams
console.log(mulberry32(43)() === mulberry32(42)()); // false, a new seed

A seeded generator: same seed, same sequence, every run.

Clamp and lerp: the two functions you will keep rewriting

  clamp(v, 0, 10)      lerp(a, b, t)

  v = -3  ->  0        t=0    t=0.5    t=1
  v =  4  ->  4        |--------|--------|
  v = 14  -> 10        a        mid      b

  t outside 0..1 extrapolates past the ends.
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
const lerp = (a, b, t) => a + (b - a) * t;

console.log(clamp(120, 0, 100), clamp(-5, 0, 100)); // 100 0
console.log(lerp(0, 200, 0.25));                    // 50
console.log(lerp(20, 24, 0.5));                     // 22, halfway
console.log(lerp(0, 10, 1.5));                      // 15, t is not clamped

// Progress bars, scroll effects and animations are all one of these two.
const scrollPercent = (y, height) => clamp(y / height, 0, 1);
console.log(scrollPercent(1500, 1000)); // 1

Why 0.1 + 0.2 is not 0.3

console.log(0.1 + 0.2);            // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);    // false
console.log(0.1 + 0.2 - 0.3);      // 5.551115123125783e-17

// Compare with a tolerance instead of ===
const near = (a, b, eps = Number.EPSILON * 8) => Math.abs(a - b) < eps;
console.log(near(0.1 + 0.2, 0.3)); // true
console.log(Number.EPSILON);       // 2.220446049250313e-16

Every number in JavaScript is a IEEE 754 double, a binary fraction. 0.1 has no exact binary representation for the same reason 1/3 has no exact decimal one, so the stored value is very slightly off and the error shows up when you add. This is not a JavaScript bug: Python, Java and C do the same thing. What JavaScript lacks is a decimal type, which is why the fix has to be yours.

console.log((0.1 + 0.2).toFixed(2));        // '0.30', a string
console.log(typeof (1.5).toFixed(0));      // 'string'
console.log((1.005).toFixed(2));           // '1.00', not '1.01'
console.log((2.675).toFixed(2));           // '2.67', same reason
console.log(1.005 * 100);                  // 100.49999999999999, there is the culprit
console.log(Number((0.1 + 0.2).toFixed(2))); // 0.3, back to a number

toFixed returns a string, and it rounds the stored value, not the one you typed.

Money: count in the smallest unit

Floats for money

let total = 0;
for (let i = 0; i < 3; i++) {
  total += 19.99;
}
console.log(total);
// 59.97000000000001

Integer pennies

let total = 0;
for (let i = 0; i < 3; i++) {
  total += 1999;
}
console.log(total / 100);
// 59.97

Store 1999, not 19.99. Integers below 2 ** 53 are exact, so addition, multiplication by a quantity and comparison are all exact. Divide by 100 once, at the edge of the system, only to display.

const toPence = (pounds) => Math.round(pounds * 100);
const fromPence = (pence) => (pence / 100).toFixed(2);

console.log(toPence(19.99));        // 1999
console.log(toPence(0.1 + 0.2));    // 30, the drift rounded away
console.log(fromPence(1999 * 3));   // '59.97'

// Splitting a bill without losing a penny.
const split = (pence, ways) => {
  const base = Math.floor(pence / ways);
  const extra = pence - base * ways;
  return Array.from({ length: ways }, (_, i) => base + (i < extra ? 1 : 0));
};
console.log(split(1000, 3));                       // [334, 333, 333]
console.log(split(1000, 3).reduce((a, b) => a + b)); // 1000, exactly

Parsing and formatting

console.log(Number('42'), Number(''), Number('12px'));   // 42 0 NaN
console.log(parseInt('12px', 10), parseFloat('3.5rem'));  // 12 3.5
console.log(parseInt('', 10));                            // NaN
console.log(Number.isInteger(5.0), Number.isInteger(5.5)); // true false
console.log(Number.isFinite('5'), isFinite('5'));         // false true
console.log(Number.MAX_SAFE_INTEGER);                     // 9007199254740991
console.log(9007199254740992 + 1);                        // 9007199254740992
const n = 1234567.891;

console.log(n.toFixed(2));        // '1234567.89', no separators
console.log(n.toLocaleString('en-GB', { maximumFractionDigits: 0 }));
// '1,234,568'

console.log((0.256).toLocaleString('en-GB', { style: 'percent', maximumFractionDigits: 1 }));
// '25.6%'

console.log((1999 / 100).toLocaleString('en-GB', { style: 'currency', currency: 'GBP' }));
// '£19.99'

For anything a human reads, hand formatting to Intl (lesson 9.7).

remove decimals
Math.trunc, not Math.floor (negatives differ)
round to n places
Number(v.toFixed(n)), and know it rounds the float
random integer
Math.floor(Math.random() * (max - min + 1)) + min
testable randomness
inject the generator, seed it in tests
keep in range
Math.min(Math.max(v, lo), hi)
compare floats
Math.abs(a - b) < Number.EPSILON * 8
money
integer minor units, format only on output
validate a number
Number.isFinite(Number(input))

Try it yourself

Round to any precision

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

console.log(roundTo(3.14159, 2)); // 3.14
console.log(roundTo(2.5), Math.round(2.5));
console.log(roundTo(-2.5), Math.round(-2.5));

const roundHalfAwayFromZero = (n) => Math.sign(n) * Math.round(Math.abs(n));
console.log(roundHalfAwayFromZero(-2.5)); // -3, the accountant's answer

for (const v of [1.005, 2.675, 1.45]) {
  console.log(v, v.toFixed(2), roundTo(v, 2));
}

Add a roundTo(value, step) that snaps to the nearest 0.05. Then make roundHalfAwayFromZero and compare it with Math.round on negative halves.

Seeded shuffle

function mulberry32(seed) {
  return function next() {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function shuffle(items, rand = Math.random) {
  const out = [...items];
  for (let i = out.length - 1; i > 0; i -= 1) {
    const j = Math.floor(rand() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

const deck = [1, 2, 3, 4, 5, 6, 7, 8];
console.log(shuffle(deck, mulberry32(7)));
console.log(shuffle(deck, mulberry32(7)));
console.log(shuffle(deck));

Run it twice: the seeded shuffle is identical, the unseeded one is not. Then write a weightedPick that takes { value, weight } pairs.

Exercises

clamp, lerp and mapRange

Write three functions. clamp(value, min, max) keeps value inside the range. lerp(a, b, t) returns the value t of the way from a to b, and does not clamp t. mapRange(value, inMin, inMax, outMin, outMax) rescales a value from one range to another, built out of the other two.

Money in whole pennies

Write toPence(pounds) which converts a decimal amount to whole pennies, rounded to the nearest penny. Then write splitPence(total, ways) which divides an integer number of pennies into ways shares that sum to exactly total, giving the leftover pennies to the earliest shares. If ways is less than 1, return an empty array.

Check yourself

What does this log?
'0.30' false — toFixed returns a string, and it is padded to two places, so "0.30". The comparison is still false, because rounding for display does not change the stored value. Fix the comparison with a tolerance, not with toFixed.
Which function removes the fractional part of a number without ever changing its sign or magnitude direction?
Math.trunc — trunc always moves towards zero, so -2.9 becomes -2. floor moves towards negative infinity, giving -3, which is a different answer for every negative number with a fraction.
What is Math.max() with no arguments?
-Infinity — It is the identity for maximum: any real number beats it. That makes Math.max(...emptyArray) return -Infinity, which then poisons the rest of your arithmetic. Seed the call with a sensible floor, such as Math.max(0, ...values).
You are storing prices. Which is safest?
An integer number of pennies, such as 1999 — Integers up to 2 ** 53 are exact, so pennies add and multiply without drift. Strings cannot be arithmetic without parsing back to a float, and rounding after each step hides the drift rather than removing it. Convert to pounds only for display.

Common mistakes

  • Using Math.floor to drop decimals, then meeting a negative number.
  • Expecting Math.round(-2.5) to be -3 like a spreadsheet.
  • Treating toFixed output as a number, or trusting it to round 1.005 upwards.
  • Comparing computed floats with === instead of a tolerance.
  • Math.max(...values) on a possibly empty array, which yields -Infinity.

Takeaways

  • floor goes down, ceil goes up, trunc goes towards zero, round breaks ties towards positive infinity.
  • Math.random() returns [0, 1), so an inclusive integer range needs max - min + 1.
  • Inject the random generator so tests can seed it.
  • Clamp and lerp cover most of the numeric work in UI code.
  • Floats are binary approximations: compare with a tolerance and store money as integer minor units.
  • toFixed returns a string and rounds the stored value, not the decimal you typed.