Numbers and Floating Point
One number type for everything, the arithmetic that surprises you, NaN and how it spreads, and what to reach for when you are counting money.
One number type for everything, the arithmetic that surprises you, NaN and how it spreads, and what to reach for when you are counting money.
Open a terminal and add two numbers a ten-year-old could handle:
console.log(0.1 + 0.2); // 0.30000000000000004Nothing is broken. This is not a fault in the language and not something you can configure away — it happens in nearly every programming language, for a reason worth ten minutes of your life. By the end of this lesson you will know why, when it matters, and what to reach for when you are counting money.
Most languages have several numeric types — whole numbers, decimals, small ones, large ones. Here there is one:
const count = 400; // number
const price = 12.99; // number
const negative = -7; // numberThat is simpler, and it means the decimal behaviour applies to
everything. 400 and 400.0 are the same value, stored the
same way.
The arithmetic operators:
10 + 3; // 13
10 - 3; // 7
10 * 3; // 30
10 / 3; // 3.3333333333333335
10 % 3; // 1 the remainder
10 ** 3; // 1000 ten to the power of threeDivision always produces a decimal — 10 / 2 is 5, not a
separate whole-number type, because there is no separate type.
For a whole result, discard the rest deliberately:
Math.floor(10 / 3); // 3 round down
Math.trunc(-10 / 3); // -3 cut toward zero
Math.round(10 / 3); // 3
Math.ceil(10 / 3); // 4 round upMath.floor and Math.trunc differ on negatives — floor rounds
down to -4, trunc cuts toward zero to -3. Choosing the
wrong one produces an answer that is off by one, but only
sometimes.
% is the remainder, and it answers two questions that come
up constantly:
seconds % 60; // what is left over
count % 2 === 0; // is it even
index % 100 === 0; // every hundredth time round a loopHere is the reason, and it is not about computers being unreliable.
Write one third as a decimal. You get 0.333333…, forever. It cannot be written exactly in base ten with a finite number of digits. Nobody finds that alarming — it is a fact about the notation, not about the number.
Computers store numbers in base two, and in base two the number that cannot be written exactly is one tenth. So 0.1 is stored as the closest value that fits, which is very slightly off:
you write 0.1
stored as 0.1000000000000000055511151231257827021181583404541015625Add two of those slightly-wrong values and the error becomes visible:
0.1 + 0.2 === 0.3; // falseThe lesson is not "numbers are broken". It is that a number is
an approximation of a decimal value, accurate to about
seventeen significant digits, and asking whether two of them are
exactly equal is asking a question the type cannot answer.
Since exact equality is the wrong question, ask a better one: is the difference small enough not to matter?
Bad — comparing two decimals for exact equality.
const total = 0.1 + 0.2;
if (total === 0.3) {
markInvoicePaid();
}Good — asking whether they are close enough.
const total = 0.1 + 0.2;
if (Math.abs(total - 0.3) < Number.EPSILON) {
markInvoicePaid();
}The first version leaves the invoice unpaid and leaves no trace
of why. There is no error and no warning — the customer paid the
right amount, the comparison said false, and the only symptom
is a support ticket next week.
Number.EPSILON is the smallest meaningful difference between
two numbers near 1. For larger values, scale the tolerance to
the size of the numbers involved, or — better — avoid the
comparison entirely, which the next section is about.
Whole numbers are exact, so === on them is fine. The problem
is decimals specifically.
Everything above leads here. Never store money in a
number.
Money has exact decimal values. A price is 19.99, not approximately 19.99, and a total that is out by a hundredth of a penny per transaction becomes a real discrepancy across a hundred thousand of them — the kind that appears in an audit and takes a week to trace.
const price = 0.1;
const total = price * 3;
console.log(total); // 0.30000000000000004
console.log(total.toFixed(2)); // "0.30" - looks rightThat second line is the dangerous part. Formatting hides the error on screen while it stays in the stored value, so the display reassures you and the database does not.
The straightforward fix is to work in the smallest unit — pence or cents — as whole numbers:
const priceInPence = 1299; // £12.99
const totalInPence = priceInPence * 3; // 3897, exactly
function format(pence: number): string {
return `£${(pence / 100).toFixed(2)}`;
}
console.log(format(totalInPence)); // £38.97Whole numbers are exact up to about 9 quadrillion, so the arithmetic cannot drift. Divide only when displaying.
For larger amounts or more complex arithmetic, a decimal
library (decimal.js, dinero.js) stores digits exactly. The
principle is the same: keep money out of decimal number
arithmetic.
Two special values live inside the number type, and both catch
people.
console.log(1 / 0); // Infinity
console.log(Number("hello")); // NaNNaN means "not a number", and it is what you get from
arithmetic that has no meaningful answer. Its defining property
is that it spreads:
const price = Number(formInput); // NaN if the input was "abc"
const total = price * quantity; // NaN
const withTax = total * 1.2; // NaNOne conversion fails
Number("abc") gives NaN rather than raising. The form
field was empty, or had a stray letter in it.
Every later calculation inherits it
NaN * quantity is NaN. NaN * 1.2 is NaN. It travels
through your whole pipeline unchanged.
It lands somewhere real
A NaN in a database column, or the literal text "NaN" on
a customer's screen. The bug report names the screen, not the
conversion.
And the trap that makes it hard to detect:
NaN === NaN; // false - it is not equal to itselfSo checking for it needs a purpose-built test:
Number.isNaN(value); // the correct check
Number.isFinite(value); // not NaN, and not InfinityUse Number.isNaN, not the older global isNaN, which converts
its argument first and therefore reports true for "hello"
before it has established anything useful.
Values from forms and files arrive as text, and converting is where they go wrong.
Number("42"); // 42
Number("3.5"); // 3.5
Number(""); // 0 <- an empty box becomes zero
Number(" 7 "); // 7 whitespace is fine
Number("12abc"); // NaN
parseInt("12abc"); // 12 <- stops at the first letter
parseFloat("3.5kg"); // 3.5Two of those lines are the ones that reach production.
A blank field becomes a quantity.
A customer who left the box empty gets zero rather than an error, and the order goes through.
It stops at the first letter.
Occasionally that is what you want — reading "20px" — and
usually it means malformed input was accepted silently.
Prefer Number() plus a Number.isFinite check, so a bad value
is rejected rather than partly accepted.
For very large whole numbers beyond Number.MAX_SAFE_INTEGER
(about 9 quadrillion), there is a separate type written with an
n suffix:
const huge = 9007199254740993n; // bigintYou will rarely need it. Know it exists so that when a large identifier from another system loses its last digits, you recognise the cause.
(1234.5678).toFixed(2); // "1234.57" - a STRING
(0.156).toFixed(1); // "0.2"
new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
}).format(38.97); // "£38.97"toFixed returns a string, not a number, which means
total.toFixed(2) + 5 joins text rather than adding.
Intl.NumberFormat handles thousands separators, currency
symbols and the decimal conventions of different countries — a
German reader expects 1.234,57 where a British one expects
1,234.57. Building that by hand gets it wrong for most of the
world.
ONE TYPE
number covers whole and decimal alike; 400 and 400.0 are one
bigint (9007199254740993n) for very large whole numbers
ARITHMETIC
+ - * / % **
/ always gives a decimal - there is no integer division
Math.floor round down Math.trunc cut toward zero
Math.ceil round up Math.round
floor and trunc differ on NEGATIVES - off by one, sometimes
n % 60 the remainder
n % 2 === 0 is it even
i % 100 === 0 every hundredth
THE APPROXIMATION
0.1 + 0.2 === 0.3 false
a number is accurate to ~17 significant digits
whole numbers are exact; decimals are not
this is IEEE 754, not this language
Math.abs(a - b) < Number.EPSILON compare decimals this way
MONEY
never in a decimal number
toFixed HIDES the error on screen; it stays in the value
work in pence/cents as whole numbers, divide only to display
or a decimal library for anything complex
NaN AND Infinity
Number("hello") -> NaN 1 / 0 -> Infinity
NaN spreads silently through every later calculation
NaN === NaN is FALSE
Number.isNaN(x) Number.isFinite(x) <- the correct checks
not the global isNaN, which converts first
FROM TEXT
Number("42") 42
Number("") 0 <- a blank field becomes zero
Number("12abc") NaN
parseInt("12abc") 12 <- accepts malformed input
prefer Number() + Number.isFinite, and reject on arrival
DISPLAY
(1.5).toFixed(2) a STRING, not a number
Intl.NumberFormat(locale, ...) separators and currency, correctlyYou now know why decimals are approximate, when that matters and
when it does not, and the rule that will save you a genuinely
bad day: money does not go in a decimal number. You also know
that NaN spreads without complaining, which is the most common
way a wrong number reaches a database.
Next is Booleans, Conditions, and Truthiness, where programs
stop running straight through and start choosing. It uses the
comparisons introduced here, and adds the set of values this
language treats as false — which includes 0 and NaN, and
causes a specific bug worth meeting before you write it.
Before you move on, open a terminal and add 0.1 to itself ten
times, then compare the result to 1. Then do the same with
Number("") and watch an empty string become zero. Both take
thirty seconds and fix the ideas permanently in a way that
reading about them does not.