JAVASCRIPT / NUMBERS, MATH, AND BIGINT
Rounding, flooring, and fixed decimals
Choose between Math.round, floor, ceil, trunc, and toFixed correctly, including how each treats negatives, ties, and the binary value actually stored.
What you will learn
- Pick by direction: floor to -Infinity, ceil to +Infinity, trunc toward zero
- Know that Math.round breaks ties upward, so Math.round(-2.5) is -2, not -3
- Use toFixed for display only: it returns a string and rounds the stored double
- Round to N decimals via Number(x + 'e' + n), not by multiplying by 100
Understanding Rounding, flooring, and fixed decimals
Math.floor, Math.ceil, Math.trunc, and Math.round all hand back an integer-valued number and differ only in which direction they move and what they do with an exact half. floor always moves toward -Infinity and ceil always toward +Infinity, so on negative input they invert the "down" and "up" intuition you built on positive numbers. Math.trunc just discards the fractional digits, which makes it identical to floor for positive values and identical to ceil for negative ones.
Math.round goes to the nearest integer and resolves a tie by picking the larger of the two candidates, meaning ties move toward +Infinity rather than away from zero. That is why Math.round(2.5) is 3 and Math.round(-2.5) is -2, and why Math.round(-3.5) gives the same answer as Math.ceil(-3.5). toFixed is a different animal: it is a formatter that returns a string with exactly the requested number of decimals, it accepts 0 through 100 digits, and it strips the sign before applying its own tie rule, so (-2.5).toFixed(0) is "-3".
There is no Math function for "round to two decimals", so you scale, round, and scale back, and the scaling step is where accuracy leaks. 1.005 * 100 evaluates to 100.49999999999999 because the closest double to 1.005 sits just below it, so Math.round(1.005 * 100) / 100 collapses to 1. Building the scaled value as a decimal string instead, Number('1.005e2'), yields exactly 100.5, because shifting a decimal point inside text introduces no error at all. For money the sturdier answer is to store integer minor units and divide only when you format.
Together those two facts explain almost every rounding bug: the direction and tie rule you assumed are not the ones the function uses, or the function is rounding a binary value that is not quite the decimal you typed.
const values = [2.5, -2.5, 2.4, -2.4];
for (const n of values) {
console.log(`${n}: round=${Math.round(n)} floor=${Math.floor(n)} ceil=${Math.ceil(n)} trunc=${Math.trunc(n)}`);
}
const price = 2.675;
console.log(`toFixed(2)=${price.toFixed(2)} typeof=${typeof price.toFixed(2)}`);
console.log(`toFixed on -2.5=${(-2.5).toFixed(0)} Math.round(-2.5)=${Math.round(-2.5)}`);Rounding is two independent choices, direction and tie-breaking, and toFixed applies both to the binary double actually stored rather than the decimal literal you wrote.
Worked examples
floor and trunc part ways on negatives
Shows that cutting decimals and rounding down are the same operation only for non-negative values.
const seconds = -125;
console.log(`floor: ${Math.floor(seconds / 60)}, trunc: ${Math.trunc(seconds / 60)}`);
console.log(`floor: ${Math.floor(-7 / 2)}, trunc: ${Math.trunc(-7 / 2)}`);
console.log(`floor: ${Math.floor(7 / 2)}, trunc: ${Math.trunc(7 / 2)}`);
console.log(`ceil: ${Math.ceil(-7 / 2)}, round: ${Math.round(-7 / 2)}`);Example explained
Line 1-125 / 60 is -2.083..., so floor steps down to -3 while trunc deletes the fraction and stops at -2.
Line 2-7 / 2 is -3.5: floor gives -4 (further from zero) and trunc gives -3 (toward zero).
Line 3For 7 / 2 both give 3, which is why mixing them up stays invisible until a value goes negative.
Line 4Math.round(-3.5) equals Math.ceil(-3.5) because the tie is resolved toward +Infinity.
Why 1.005 will not round to 1.01
Reveals the stored value behind 1.005 and fixes decimal rounding by scaling through a string.
console.log((1.005).toFixed(20));
console.log((1.005).toFixed(2));
console.log(Math.round(1.005 * 100) / 100);
function roundTo(x, digits) {
return Number(Math.round(Number(x + 'e' + digits)) + 'e-' + digits);
}
console.log(`${roundTo(1.005, 2)} ${roundTo(2.675, 2)}`);Example explained
Line 1toFixed(20) prints the digits actually stored: the nearest double to 1.005 is slightly less than 1.005.
Line 2toFixed(2) therefore sees a value below the halfway point and rounds down to "1.00".
Line 31.005 * 100 lands on 100.49999999999999, so Math.round returns 100 and dividing by 100 gives 1.
Line 4Number('1.005e2') parses to exactly 100.5, so the round happens above the halfway point and yields 1.01.
Snapping to a step, then formatting
Rounds values onto a grid and shows what happens when a toFixed string is used in arithmetic.
function toNearest(value, step) {
return Math.round(value / step) * step;
}
console.log(toNearest(17, 5));
console.log(toNearest(0.62, 0.05));
console.log(toNearest(0.62, 0.05).toFixed(2));
const total = 19.9;
console.log(total.toFixed(2) + 5);
console.log(Number(total.toFixed(2)) + 5);Example explained
Line 117 / 5 is 3.4, which rounds to 3, and 3 * 5 is 15: the value is snapped to the nearest multiple of the step.
Line 2Multiplying back by 0.05 reintroduces binary error, so the result is 0.6000000000000001 rather than 0.6.
Line 3toFixed(2) hides that trailing bit for display and also pads 0.6 out to two decimals.
Line 4'19.90' + 5 concatenates because toFixed returned a string; Number() converts it back before adding.
Important notes
Math.round returns -0 for any input in [-0.5, 0). It compares equal to 0, but consoles display -0 and 1 / result is -Infinity, so add 0 to normalize the sign.
toFixed drops fixed notation for magnitudes of 1e21 and above, so (1e21).toFixed(2) is '1e+21', and a digit count outside 0 through 100 throws a RangeError.
Common mistakes
Expecting Math.round(-2.5) to be -3 because rounding feels symmetric; it is -2, so refunds, deltas, and score adjustments come out one unit high only on negative inputs.
Reaching for Math.floor to drop decimals: Math.floor(-3.2) is -4, so an index or duration derived from a negative offset ends up one too low. Math.trunc is what cutting the fraction means.
Treating toFixed as if it returned a number, so (19.9).toFixed(2) + 5 is the string '19.905' and a column of such totals sorts lexicographically instead of numerically.
Try it yourself
Change, predict, then run
In a browser console, write roundTo(value, digits) that returns Number(Math.round(Number(value + 'e' + digits)) + 'e-' + digits), then compare roundTo(1.005, 2), Math.round(1.005 * 100) / 100, and (1.005).toFixed(2), and decide which one you would print on an invoice.
Open the JavaScript workspaceCheck your understanding
A balance adjustment is -2.5 and you need the nearest whole unit. Why do Math.round(-2.5) and (-2.5).toFixed(0) disagree?
- Math.round breaks ties toward +Infinity, while toFixed removes the sign first and then rounds the tie up, so it effectively rounds away from zero
- toFixed uses banker's rounding, so it snaps -2.5 to the nearest even integer
- -2.5 cannot be stored exactly as a double, so the two functions are rounding slightly different values
- Math.round truncates negative numbers toward zero, which is why it stops at -2
Show answer
Math.round's tie rule picks the larger candidate, so -2.5 becomes -2, whereas toFixed sets the sign aside, rounds 2.5 up to 3, and reattaches the minus for '-3'. Option 3 is tempting because precision really is the cause of the 1.005 surprise, but -2.5 is 2 + 0.5, a sum of powers of two, and is stored exactly; nothing is lost here. Banker's rounding is not used by either function, and truncation toward zero is Math.trunc, not Math.round.