Numbers in JavaScript have a number of particular properties.
In particular, there are a number of special "Numbers" which aren't ordinarily considered numbers.
Special number
Description
Infinity
The mathematical ∞, larger than any number.
-Infinity
The mathematical -∞, smaller than any number.
NaN
Not a Number, but nevertheless typeof NaN === "number".
-0
Under normal mathematics this would be equal to 0. But while 0 === -0, 1/0 === Infinity and 1/-0 === -Infinity.
However, even "normal" numbers have some peculiar properties.
0.1 + 0.2 ⇒ 0.30000000000000004
Mmmm. That seems a bit off.
Most of the rest of this post will be about why we get this result, and some kind of apology
why that behavior is kinda OK in most cases. But first things first...
When you are working with money, this is certainly not OK!
I mean, the pennies don't add up! Can there be any disaster greater in accounting than pennies which don't add up?
Now I have some amazing confession to make. The built-in JavaScript number type is, for exactly the reason we have seen above,
unsuitable to represent monetary data. And for kicks, JavaScript also doesn't provide another datatype
which would be suitable. So that leaves approximately one billion web-shop front-ends to invent their own solution to this
problem.
You may now want pick up your jaw from the carpet again.
So what to do if you have to deal with money? Well, I would recommend using an external library such as Decimal.js. With this library, you can do the following:
OK, so the syntax looks terrible but we get the correct result.
Also note that we directly convert from string to Decimal, and directly back from Decimal to string.
So no temporary results as number. This is important to avoid rounding issues.
Alternative solutions which are not so hot
If you hang out on stackunderflow or some other wisdom-of-the-masses site, you may get the following recommendations instead.
Use amount.toFixed(2) to get the end result.
Do all your accounting in pennies/cents
Periodically round back to "entire" cents using Math.round(amount*100)/100.
Of these, the first gets special mention in not even really dealing with the problem but basically just papering over it. Internally the stored number is still off. The other two solutions can be summarized as "let's build Decimal.js ourselves but with more bugs". This may be an educative exercise for the programmer without deadlines, but I'd recommend that if it is about actual money you don't invent this wheel and use something like Decimal.js.
How do JavaScript numbers work anyway?
OK, so now we have the financial data people out of the room, let's see how JavaScript numbers
actually work. JavaScript uses a number system standard known as
IEEE Standard for Floating-Point Arithmetic, or IEEE 754 among friends. In fact, IEEE 754 describes multiple number systems; the one used by JavaScript is called binary64. In binary64, a number is stored as 64 bits and it is stored as a binary (base-2) number, as opposed to some decimal (base-10) representation. This number system is also commonly known as double-precision format, or simply as "double".
So we have three fields, each of which can be considered an unsigned integer.
Now, given those three numbers, how do we get to the actual represented number \(x\)?
First we take the fraction \(F\) and divide by \(2^{52}\). That produces a number \(x_1\). Note that \(0 \le x_1 < 1\).
Add 1 to \(x_1\), producing a number \(x_2\) so that \(1 \le x_2 < 2\).
Then multiply \(x_2\) with the number \(2^{E-1023}\), producing a number \(x_3\).
Note that if \(E < 1023 \) then we are multiplying with a number smaller than 1, so we may end up with a fractional result.
Finally, if \(S = 0\) then \( = x_3\), and if \(S = 1\) then \(x = -x_3\).
This can be summarized in a single formula.
Note that there are special rules for \(E = 0\) and \(E = 2047\) (the maximum value for \(E\)).
The important take-away is that, with these rules, there is no way to represent the number 0.1 exactly. In fact, numbers like \(1/3\) or \(1/5\) also cannot be represented exactly. The only fractions which can possibly be represented exactly are ones where the denominator is a power of two. So \(1/2\) and \(3/4\) can be represented exactly, but 0.1, 0.2 and 0.3 (a.k.a. \(1/10\), \(1/5\) and \(3/10\)) cannot.
So what happens if I enter 0.1 on the JavaScript console?
You enter 0.1 in the JavaScript console and get back 0.1.
How is this possible?
Well, what really happens is that 0.1 is first rounded to the nearest number which can
be represented. That is the number with
S = 0, E = 1019 and F= 2702159776422298. In decimal, it is the number
So why doesn't the console print back that number?
Well, the thinking was that printing back the above number would be a bit excessive. So there is actually a second approximation, a step which I call un-rounding. We are going to round the number to a minimum number of (decimal) digits so that, if we convert it back to a JavaScript floating-point number,
it is still the same number. This is kind of the opposite operation of the rounding we do when converting from decimal to binary.
Un-rounding happens when the number.toString() method is invoked, or in any of
the many situations where a number is automatically converted to a string (e.g. '' + number).
Conceptually what happens can be described with the following function: we try to find the minimum precision so that the number "round-trips", i.e. rounds back to the same JavaScript floating-point number.
function unround(x) {
for (let precision = 1;; precision++) {
const str = x.toPrecision(precision);
if (Number.parseFloat(str) === x) {
return str;
}
}
}
Note that the actual implementation is cleverer than this, it doesn't really parse the float again and again. That would be deadly slow.
Un-rounding the ugly number above will again give 0.1. (Try entering it in a JavaScript console.)
A bit more about rounding
Rounding doesn't only happen when a number is converted from decimal. In fact, operations like
+, -, * and / can produce a number which cannot be represented as a JavaScript floating-point number.
In all those cases, what happens is that (at least conceptually), first the exact result is calculated, and then it is again rounded to the nearest number which can be represented.
Finally, it is possible that the exact result is precisely in the middle of two representable numbers. In that case the number which has F even is selected. This is called the round half to even rounding rule.
The story of 0.1 + 0.2
We can now reconstruct what happens exactly when evaluating 0.1 + 0.2.
The number 0.1 is parsed. It is not exactly representable so it is rounded to the nearest representable number
\(x =3{,}602{,}879{,}701{,}896{,}397 \cdot 2^{-55}\).
This is a rounding error of \(2^{-55}\cdot 5^{-1} \approx 5.55112 \cdot 10^{-18}\) in positive direction.
The number \(0.2\) is parsed. It is not exactly representable so it is rounded to the nearest representable number
\(y =3{,}602{,}879{,}701{,}896{,}397 \cdot 2^{-54}\).
This is a rounding error of \(2^{-54}\cdot 5^{-1} \approx 1.11022 \cdot 10^{-17} \) in positive direction.
The sum \(x+y\) is calculated. The exact result is \(z = 10{,}808{,}639{,}105{,}689{,}191 \cdot 2^{-55}\).
However \(z\) is not exactly representable. So it gets rounded to the nearest representable number which is
\(z' = 1{,}351{,}079{,}888{,}211{,}149 \cdot 2^{-52}\).
This is a rounding error of \(2^{-55} \approx 2.77556 \cdot 10^{-17} \) in positive direction.
Finally, \(z'\) gets un-rounded to \(0.30000000000000004\). This is an error of
\(15{,}148{,}937{,}153 \cdot 2^{-52}\cdot 5^{-17} \approx 4.4089\cdot 10^{-18}\) in negative direction.
The cumulative error is of course \(4 \cdot 10^{-17}\). From the above we can see that all four errors are
in the same ballpark of magnitude, although the rounding after summing dominates.
Is this error bad? On one hand, the number returned is not the mathematically right one.
On the other hand, if we could measure the average distance of the Sun to Neptune with this accuracy,
we would be making an error of less than a millimeter. That is mind-boggling accurate. No actual measuring device
can produce something even resembling that accuracy. If we are using floating point numbers with measured quantities,
there is no justification in not accepting this level of inaccuracy.
Safe integers
When working with numbers, a lot of the time we are only working with integer numbers.
In that case it is good to know that there is a large range of so-called safe integers where
we don't need to worry about rounding.
A safe integer has the following properties:
It can be exactly represented as a JavaScript floating-point number.
No other integer number will ever be rounded to this number.
These two properties mean that if we have, say, the result of adding two integers a + b, and
the result is a safe integer, we know there wasn't any rounding involved. Note that this conclusion only holds
if we know the sum a + b is in fact integer, since it is still possible for a non-integer number to round to a safe integer! (For example, if you enter 9007199254740990.6 on the JavaScript console you will get back 9007199254740991, which is a safe integer.)
The safe integers are the integers \(n\) with \(-2^{53}+1 \le n \le 2^{53}-1\). These limits are also available in JavaScript as Number.MIN_SAFE_INTEGER and Number.MAX_SAFE_INTEGER. You can also directly check if a number is a safe integer with Number.isSafeInteger(n).
Note that this is a pretty big range, also considering that the largest possible array in JavaScript is only \(2^{32}-1\).
If it is still not enough, then there is currently a proposal for BigInt support in JavaScript. This would allow arbitrary large integers, only limited by the memory in your computer.
Special numbers: NaN, -0, ±Infinity
Let's talk a bit more about these four weird numbers which aren't part of the normal real number line:
NaN, -0, Infinity and -Infinity.
The first of them, NaN, is what you get when a mathematical operation doesn't have a defined result.
So for example 0/0 gives NaN, and similarly Math.sqrt(-1). Note that
NaN has a very peculiar property; it is the only value in JavaScript which is
unequal to itself. So in fact NaN === NaN returns false.
Note that this means that if you want to check for NaN, you shouldn't do number === NaN, but rather use the isNaN function like so: isNaN(number).
The situation with -0 and 0 is a bit like the reverse of the NaN situation; while these two values are distinct, we have 0 === -0. So these values can be almost used interchangeably, except that in a few situations operations on them produce different results. So for example, 1/0 gives Infinity, but 1/-0 gives -Infinity. And Infinity !== -Infinity.
Sometimes it is useful to check if two numbers are really the same. In that case, you can use the function Object.is. This is the same as ===, except for NaN and ±0. So Object.is(NaN, NaN) produces true and Object.is(0, -0) produces false.
Floating-point identities
Under normal mathematics, addition is associative, which means that for arbitrary numbers \(x\), \(y\) and \(z\), the equality \( (x + y) + z = x + (y + z)\) holds. However, with JavaScript floating-point numbers a rounding step occurs after each addition, so this equality is generally not true for JavaScript numbers. For example, try (0.1 + 0.2) + 0.3 and compare with 0.1 + (0.2 + 0.3).
So that is the bad news. The good news is that there are still a number of identities which hold even for
floating-point numbers. So for example, addition is still commutative, meaning that
x + y === y + x.
The following table shows a number of useful identities which hold for any JavaScript number (unless otherwise mentioned), so also
for NaN, ±Infinity and ±0.
Equality is in terms of Object.is, so these identities preserve NaN and the sign of ±0.
\(x + y \equiv y + x\)
\(x * y \equiv y * x\)
\(- (- x) \equiv x\)
\(x - y \equiv x + (- y)\)
\(x - 0 \equiv x\)
\(x + 0 \equiv x \), for \(x \not\equiv -0\). Note: \((-0) + 0 \equiv 0\).
\(1 * x \equiv x\)
\( (-1) * x \equiv -x \)
\(2 * x \equiv x + x\)
\( x - x \equiv 0 \), for \(x \not\in \) {Infinity, -Infinity, NaN}.
Floating-point identities. The symbol \(\equiv\) means equality in the sense of Object.is.
Bonus section: Computing S, E and F in JavaScript
Here is some code to find S, E and F for an arbitrary JavaScript number.
This code is actually in a <script> tag, so you can now open your JavaScript console
and try it out live (typically with F12).