0.30000000000000004

Published

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.

But first I’ll introduce some context around how I found this bug in my React app. Click here if you want to skip straight to solutions.

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.

Screenshot from Guitar Pro 7's manual labeling the following toolbar controls: Show/hide interface panels, Zoom, Display modes, Undo/redo, Print, Navigation and playback, LCD, Loop and playback settings, Global tonality, Instrument views, Tuner, Line-in, and Fretlight

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.

Guitar Pro 7's zoom control

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?

Guitar Pro 7's zoom control, showing dropdown options including 25%, 100%, 800%, Fit to Width, Fit to Page, and Custom

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"].

Guitar Pro 7's zoom control, displaying a numerical input prompting the user to type in a zoom percentage

But that can wait until later, too. Here’s my first pass at an unstyled Zoom component:

Zoom.js
import React from 'react';
const zoomLevels = [
'Fit to Width',
'Fit to Page',
0.25,
0.5,
0.75,
0.9,
1,
1.1,
1.25,
1.5,
2,
3,
4,
8,
'Custom...',
];
const Zoom = () => (
<div className="Zoom">
<select name="zoom-dropdown" className="Zoom__Dropdown">
{zoomLevels.map((zoomLevel) => (
<option key={zoomLevel}>
{isNaN(zoomLevel) ? zoomLevel : zoomLevel * 100 + '%'}
</option>
))}
</select>
<input type="range" name="zoom-slider" className="Zoom__Slider" />
</div>
);
export default Zoom;

Simple enough, right? Just format all the numbers as percentages, and render the strings as strings. But here’s what I got:

FretZone's zoom control options, showing the same options as those of Guitar Pro, but 110% is instead 110.0000000000000001%

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.

> 0.1 + 0.2
0.30000000000000004

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.

Examples of terminating and repeating decimals in base 10. Note that 0.1 does not repeat in base 10. Examples of terminating and repeating decimals in base 2. Note that 0.1 repeats infinitely in base 2.

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).

Formula to calculate a binary64's numerical value based on a sign bit, 52 significand bits, and 11 exponent bits
Formula to calculate a binary64’s numerical value
Diagram showing how 64 bits are allocated to represent numbers in double-precision floating-point format: 1 bit for the sign, 11 bits for the exponent, and 52 bits for the significand
How a binary64’s bits are allocated in memory

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!)

> 0.1000000000000000055511151231257827021181583404541015625
0.1
> 0.200000000000000011102230246251565404236316680908203125
0.2

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…

> 0.1000000000000000055511151231257827021181583404541015625 + 0.200000000000000011102230246251565404236316680908203125
0.30000000000000004

And when I’m multiplying 1.1 by 100

> (1 + 0.1000000000000000055511151231257827021181583404541015625) * 100
110.00000000000001

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.

Functions that return numbers

When you want to use calculated values in future calculations, you might want to use these methods.

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:

// https://learnersbucket.com/examples/javascript/learn-how-to-round-to-2-decimal-places-in-javascript/
let roundOff = (num, places) => {
const x = Math.pow(10, places);
return Math.round(num * x) / x;
};

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:

> +(0.1 + 0.2).toFixed(1)
0.3
// My preferred version of the utility function above
const roundToDecimalPlaces = (num, places) => +num.toFixed(places);

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.)

const formatPercentage = (n) => {
n *= 100;
if (n >= 1) {
n = n.toFixed(0);
}
return n + '%';
};
// ...
zoomLevels.map((zoomLevel) => (
<option key={zoomLevel}>
{isNaN(zoomLevel) ? zoomLevel : formatPercentage(zoomLevel)}
</option>
));
// ...

Further reading