I found a rounding bug in Number().toFixed()
in every JavaScript environment I’ve tried (Chrome, Firefox, Internet Explorer, Brave, and Node.js). The fix is surprisingly simple. Read on…
Updates 28 May 2020
I’m aware that this is part of the whole integer-to-binary bug set introduced in the 1960s. I am just surprised that it has been tolerated for 50 years.
This post is a bit long; you can skip to the ADDENDUM 28 MAY 2020 for my latest thoughts.
Warm up
I found this version of the rounding bug in toFixed()
while revising a number-formatting function that performs the same kind of thing as Intl.NumberFormat#format()
.
(1.015).toFixed(2) // returns "1.01" instead of "1.02"
The failing test is on line 42 here: https://gist.github.com/dfkaye/0d84b88a965c5fae7719d941e7b99e2e#file-number-format-js-L42. I had missed it until yesterday (4 Dec 2017), and that spurred me to check for other problems.
See my tweets about it:
+ Bug Alert
+ Compare with Intl.NumberFormat
+ Wrap Up
+ Polyfill
Bug reports
There is a long history of bug reports with respect to rounding errors using toFixed()
.
- Chrome https://forums.asp.net/t/1966237.aspx?toFixed+not+working+in+Chrome
- Firefox http://forums.mozillazine.org/viewtopic.php?f=9&t=999945
- see also https://bugzilla.mozilla.org/show_bug.cgi?id=186563
- Internet Explorer https://stackoverflow.com/questions/10470810/javascript-tofixed-bug-in-ie6
Here is a short sample of StackOverflow questions about this problem:
+ [https://stackoverflow.com/questions/12105787/tofixed-javascript-function-giving-strange-results]
+ [https://stackoverflow.com/questions/5490687/broken-tofixed-implementation]
In general, these point out a bug for a value, but none reports a range or pattern of values returning erroneous results (at least none that I have found, I may have missed something). That leaves the programmers to focus on the small without seeing a larger pattern. I don’t blame them for that.
Finding the pattern
Unexpected results based on input must arise from a shared pattern in the input. So, rather than review the specification for Number().toFixed()
, I focused on testing with a series of values to determine where the bug shows up in each series.
Test function
I created the following test function to exercise toFixed()
over a series of integers ranging from 1 to a maxValue
, adding the fraction
such as .005 to each integer. The fixed
(number of digits) argument to toFixed()
is calculated from the length of the fraction
value.
function test({fraction, maxValue}) { // Happy side-effect: `toString()` removes trailing zeroes. fraction = fraction.toString() var fixLength = fraction.split('.')[1].length - 1 // All this to create the expectedFraction message... var last = Number(fraction.charAt(fraction.length - 1)) var fixDigit = Number(fraction.charAt(fraction.length - 2)) last >= 5 && (fixDigit = fixDigit + 1) // Replace last two digits with single `fixDigit` var expectedFraction = fraction.replace(/[\d]{2,2}$/, fixDigit) return Array(maxValue).fill(0) .map(function(ignoreValue, index) { return index + 1 }) .filter(function(integer) { // Compares 1.015 to 1.0151 b/c fixing by more than one decimal place rounds correctly. var number = integer + Number(fraction) // number 1.015 var actual = number.toFixed(fixLength) // string "1.015" var expected = Number(number + '1').toFixed(fixLength) // string "1.0151" // Report failures return expected != actual }) .map(function(integer) { // Format reported failures var number = Number(integer) + Number(fraction) return { given: number.toString(), expected: (Number(integer.toFixed(0)) + Number(expectedFraction)).toString(), actual: number.toFixed(fixLength) } }) }
Usage
The following example executes on integers 1 through 128, adding the fraction .015 to each, and returns an array of “unexpected” results. Each result contains a given
, expected
, and actual
field. Here we consume the array and print each item.
test({ fraction: .015, maxValue: 128 })
.forEach(function(item) {
console.log(item)
})
Output:
For this case, there are 6 unexpected results.
Object { given: "1.015", expected: "1.02", actual: "1.01" }
Object { given: "4.015", expected: "4.02", actual: "4.01" }
Object { given: "5.015", expected: "5.02", actual: "5.01" }
Object { given: "6.015", expected: "6.02", actual: "6.01" }
Object { given: "7.015", expected: "7.02", actual: "7.01" }
Object { given: "128.015", expected: "128.02", actual: "128.01" }
Findings
I found the bug consists of three parts:
- The last significant digit in the fraction must be 5 (.015 and .01500 produce the same result).
- The fixing length must shorten the fraction by only one digit.
- The bug appears inconsistently as different integer values are applied.
Inconsistently?
For example, (value).toFixed(2)
with different 3-digit fractions ending in 5, for integers 1 though 128, produces these results:
- fixing numbers ending with .005 ALWAYS fails (!!)
- fixing numbers ending with .015 fails for 1, then 4 through 7, then 128
- fixing numbers ending with .025 fails 1, 2, 3, then 16 through 63
- fixing numbers ending with .035 fails for 1, then 32 through 128
- fixing numbers ending with .045 fails for 1 through 15, then 128
- fixing numbers ending with .055 fails for 1, then 4 through 63
- fixing numbers ending with .065 fails for 1, 2, 3, then 8 through 15, then 32 through 128
- fixing numbers ending with .075 fails for 1, then 8 through 31, then 128
- fixing numbers ending with .085 fails for 1 through 7, then 64 through 127 (!!)
- fixing numbers ending with .095 fails for 1, then 4 through 7, then 16 through 128
Those of you with more binary and floating-point math knowledge than me can probably reason out the underlying cause. I leave that as an exercise for the reader.
Fixing toFixed()
Fixing a value by more than one decimal place always rounds correctly; e.g., (1.0151).toFixed(2)
returns “1.02” as expected. Both the test and polyfill use that knowledge for their correctness checks.
That means there’s a simple fix for all implementations of toFixed()
: If the value contains a decimal, append “1” to the end of the string version of the value to be modified. That may not be “to spec,” but it means we will get the results we expect without having to revisit lower-level binary or floating-point operations.
Polyfill
Until all implementations are modified, you can use the following polyfill to overwrite toFixed()
, if you’re comfortable doing that. – (Not everyone is.)
(1.005).toFixed(2) == "1.01" || (function(prototype) {
var toFixed = prototype.toFixed
prototype.toFixed = function(fractionDigits) {
var split = this.toString().split('.')
var number = +(!split[1] ? split[0] : split.join('.') + '1')
return toFixed.call(number, fractionDigits)
}
}(Number.prototype));
Then run the test again and check that the length of the results is zero.
test({ fraction: .0015, maxValue: 516 }) // Array []
test({ fraction: .0015, maxValue: 516 }).length // 0
Or just run the initial conversion that started off this post.
(1.015).toFixed(2) // returns "1.02" as expected
Thank you for reading 🙂
ADDENDUM 28 MAY 2020
I HAVE FINALLY UPDATED THE POLYFILL WITH A. SHAH’S FIX, TO ACCOUNT FOR THE PADDING CASE, so that (1.005).toFixed(4)
produces “1.0050” and not “1.0051”.
(1.005).toFixed(2) == "1.01" || (function(prototype) {
var toFixed = prototype.toFixed;
prototype.toFixed = function(fractionDigits) {
var split = this.toString().split('.');
var number = Number(!split[1]
? split[0]
: (split[1].length >= fractionDigits
? split.join('.') + '1'
: split.join('.')));
return toFixed.call(number, fractionDigits);
}
}(Number.prototype));
However, I think this toLocaleString
solution (found on stackoverflow in February 2020) is the best:
(1.005).toFixed(2) == "1.01" || (function() {
Number.prototype.toFixed = function(fractionDigits) {
return this.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
});
};
}());
Addendum
I discovered an alternate fix on stackoverflow: https://stackoverflow.com/a/48356552
dated :
1.015 can not be presented accurately in Javascript. What we get actually is somewhat like 1.0149999999999999023. Calling .toFixed(2) will produce 1.01 as expected. Unless we are talking about .toFixed routine for strings, this should be considered a feature instead of a bug.
Thanks for the reply, Auguste. Sorry that I haven’t been hanging out on my blog for so long.
To your point, I am mot sure I understand the argument from the example you provided. 1.01499 should round down to 1.01 as you state using (1.01499).toFixed(2). Given (1.015).toFixed(2), however, I would expect that to return 1.02.
I wrote about this issue because so many accept the industry argument that decimals cannot be represented accurately. If that’s true, then methods that manipulate numeric values should take that into account in order to produce expected results.
Tried your polyfill above. If a = 1.005, then a.toFixed(4) returns me 1.0051 instead of 1.0050
Oh no… I was so focused on the trimming case, I didn’t consider the padding case. Thanks for pointing that one out.
I saw that when you are adding decimals the polyfill doesnt work…
Yep, polyfill only handles the trim case but does not handle the padding case. Curiously someone else pointed that out about 2 weeks ago. No plans to fix this right away but I would welcome your suggestions.
This works beautifully for rounding off decimals. Thank you!!
However, I am not familiar with javascript enough to understand the first line: (1.005).toFixed(2) == “1.01” || (function (prototype) {
Could you please point me to an explanation? Thanks!
Thanks very much for that – you had me at “works” but “beautifully” helps. 🙂
That expression can be reduced to
a || (b())
which is equivalent toif (!a) { b() }
, meaning ifa
is not true, then execute the functionb
.In our case,
a
is the test for two conditions where the erroneous rounding occurs: 1. “The last significant digit in the fraction must be 5” (we test against 1.005), and 2. “The fixing length must shorten the fraction by only one digit” – i.e., from 3 decimal places to 2.Make sense?
Absolutely, thanks a lot!
I used this fix in our application, and found that numbers like “1.5” were being converted to “1.51”. I modified your fix to handle this:
var number = +(!split[1] ? split[0] : (split[1].length >= fractionDigits ? split.join(‘.’) + ‘1’ : split.join(‘.’)))
I’ve updated this post with two code blocks – one incorporates your fix (thank you for that!) – the other is the stackoverflow version that used toLocaleString(). Have to say I like that solution.
https://dfkaye.wordpress.com/2017/12/06/number-tofixed-rounding-errors-broken-but-fixable/#2020-05-28