Have you ever run into the number 0.30000000000000004 or a similar number while coding? Maybe you've noticed that 0.1 + 0.2 !== 0.3
. This is my attempt at explaining how and why this is the case.
How I found this bug
Even though a lot of what I’ve learned so far with FretZone is specific to React, sometimes I encounter bugs in it that have to do not just with the fundamentals of JavaScript—but with the fundamentals of how computers represent information!
I’m cloning Guitar Pro in the form of FretZone to practice Thinking in React: the process of breaking down UIs into hierarchical components that communicate with each another to represent application state. Somewhere along the way I realized that FretZone could be a fully functional guitar tab editor too! But in order to make the project presentable, I decided I should keep following the process and stub out as much of the UI as possible with JSX and CSS before attempting to make it all work.
The toolbar is an integral part of Guitar Pro’s UI. After creating a basic app skeleton, it’s more or less where I’m starting to build components. I wasn’t sure what to call most of the toolbar controls, so I consulted Guitar Pro’s manual.
As expected, the manual includes a breakdown of the toolbar. The checkbox-like buttons (#1 above) to show/hide interface panels were actually among the first parts of the UI I implemented, so next up was the zoom control (#2). Here’s a closer look.
With a select
on top and an input[type="range"]
below to control the open document’s zoom level, I thought this would be a cool little component to build out. What are the dropdown options?
A list of mostly numbers, with some extra options at the top and bottom. How to render horizontal separators there will have to wait until I decide on whether to pull in open-source menu components or build them myself.
Interestingly, selecting “Custom…” replaces the dropdown options with an input[type="number"]
.
But that can wait until later, too. Here’s my first pass at an unstyled Zoom
component:
Simple enough, right? Just format all the numbers as percentages, and render the strings as strings. But here’s what I got:
Whoa there! What do you mean, “110.0000000000000001%”? 🤔
That looked awfully similar to the dreaded number 0.30000000000000004. I’d heard of it before, but hadn’t previously encountered it.
What’s going on here?
I got a weird output because I’m multiplying 1.1 * 100
.
1.1 in computer memory actually contains a minuscule rounding error that rears its ugly head when it, too, gets multiplied by 100. A common way to demonstrate errors like this is to evaluate 0.1 + 0.2
in a JavaScript environment. You can test this out in your DevTools console or by running node
in a terminal.
You might think that JavaScript is broken after seeing this. However, it’s not so much that JavaScript is broken, so much as the numbers themselves are broken.
Why?
Short explanation
Humans usually represent numbers in with a decimal (base 10) system. There are plenty of numbers which can’t be represented precisely with decimal numbers, like 1/3 = 0.333...
(repeating). As the fractional digits in numbers like these continue, those digits become less significant to us. So we often make do with approximations like 0.33
(non-repeating) when performing calculations. In doing so, we lose some precision.
Computers represent numbers with a binary (base 2) system, and likewise represents plenty of numbers imprecisely too.
Long explanation
In a given number base, a decimal doesn’t repeat (is a terminating decimal) if and only if the denominator in its fractional representation shares all prime factors with the base.
JavaScript does all arithmetic in double-precision floating-point format. Also known as binary64, this is the same format as Java and C’s double
type. It’s a way to encode numeric values into 64 bits, with a format that’s kind of like scientific notation. If you only care about a certain number of significant digits, then you can represent a huge range of numbers in a tiny amount of space by taking those digits and multiplying them by some exponent to get the appropriate magnitude. That is to say, 6.022 x 1023 is a lot more compact and easy to comprehend than 602,214,150,000,000,000,000,000.
The technical standard for floating-point arithmetic, IEEE 754, specifies that only up to 52 significant bits (b in the formula below) are stored for any binary64 number. 1 bit stores the number’s sign, and the remaining 11 bits store the exponent (e).
So if a number contains infinite repeating digits in its binary representation (like 0.1), its significant digits have to be rounded to fit into 52 bits for a computer to understand it—which introduces rounding errors.
To quote Why 0.1 Does Not Exist In Floating-Point by Rick Regan:
Let’s see what 0.1 looks like in double-precision. First, let’s write it in binary, truncated to 57 significant bits:
0.000110011001100110011001100110011001100110011001100110011001…2
Bits 54 and beyond total to greater than half the value of bit position 53, so this rounds up to
0.00011001100110011001100110011001100110011001100110011012
In decimal, this is
0.1000000000000000055511151231257827021181583404541015625
which is slightly greater than 0.1.
Any time you’re dealing with the number 0.1 in code, it’s not really 0.1 at all. Normally the rounding error is too insignificant for us to care. (Try evaluating these numbers in your JavaScript console too!)
But if you add or multiply values with rounding errors, those errors can accumulate and give unexpected results. When you’re adding 0.1
and 0.2
, you’re really adding these…
And when I’m multiplying 1.1
by 100
…
I used this binary converter to obtain the hideous true forms of 0.1
and 0.2
, rounding the infinite binary fractions to 52 significant bits.
Like Rick, first I converted 0.2
to binary which gave 0.0011001100110011001100110011001100110011001100110011012
. Converting back to decimal gave 0.200000000000000011102230246251565404236316680908203125
. Note that in binary, the very last significant digit of this number is rounded up.
Solutions
Thankfully, JavaScript’s built-in data types include functions that help us deal with rounding errors.
Functions that return strings
These methods are suitable for when you’re done with a number once it’s been outputted.
Number.prototype.toFixed()
- Rounds
- Argument is # of decimal places to round to
5.61.toFixed(1)
→'5.6'
0.0004200.toFixed(5)
→'0.00042'
Number.prototype.toPrecision()
- Rounds
- Argument is # of significant digits (ignoring leading 0s if |number| < 1)
5.61.toPrecision(1)
→'6'
0.0004200.toPrecision(5)
→'0.00042000'
Functions that return numbers
When you want to use calculated values in future calculations, you might want to use these methods.
Math.trunc()
- Does not round
Math.trunc(46.853)
→46
Math.round()
- Rounds 🧐
Math.round(46.853)
→47
Math.round((0.1 + 0.2) * 100) / 100
→0.3
Use built-in functions to suit your needs
While searching for solutions to this bug, I found this function which uses Math.round()
to round a number to a specific number of decimal places:
For a general-purpose rounding function that returns a number, I’d prefer to use the unary +
operator with toFixed
for readability. If you’re familiar with that operator, then this should make plenty of sense:
For FretZone
Since I don’t mind returning strings for outputting UI labels, I went with toFixed
. This solution correctly formats fractional percentages like 25% and 50%, while also truncating any weird rounding errors that arise. (toFixed
does round, but the maximum relative rounding error for binary64 is 2-53, so I’m not worried about the ones place accidentally getting rounded up.)