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 zeroFour 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.
| Input | round | floor | ceil | trunc |
|---|---|---|---|---|
2.5 | 3 | 2 | 3 | 2 |
2.4 | 2 | 2 | 3 | 2 |
-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 seedA 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 numbertoFixed 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.97Store 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 decimalsMath.trunc, notMath.floor(negatives differ)round to n placesNumber(v.toFixed(n)), and know it rounds the floatrandom integerMath.floor(Math.random() * (max - min + 1)) + mintestable randomness- inject the generator, seed it in tests
keep in rangeMath.min(Math.max(v, lo), hi)compare floatsMath.abs(a - b) < Number.EPSILON * 8money- integer minor units, format only on output
validate a numberNumber.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 —
toFixedreturns a string, and it is padded to two places, so"0.30". The comparison is stillfalse, because rounding for display does not change the stored value. Fix the comparison with a tolerance, not withtoFixed. - Which function removes the fractional part of a number without ever changing its sign or magnitude direction?
Math.trunc—truncalways moves towards zero, so-2.9becomes-2.floormoves 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 makesMath.max(...emptyArray)return-Infinity, which then poisons the rest of your arithmetic. Seed the call with a sensible floor, such asMath.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.floorto drop decimals, then meeting a negative number. - Expecting
Math.round(-2.5)to be-3like a spreadsheet. - Treating
toFixedoutput as a number, or trusting it to round1.005upwards. - Comparing computed floats with
===instead of a tolerance. Math.max(...values)on a possibly empty array, which yields-Infinity.
Takeaways
floorgoes down,ceilgoes up,truncgoes towards zero,roundbreaks ties towards positive infinity.Math.random()returns[0, 1), so an inclusive integer range needsmax - 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.
toFixedreturns a string and rounds the stored value, not the decimal you typed.