Skip to content

Instantly share code, notes, and snippets.

@dfkaye
Last active April 26, 2022 16:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dfkaye/e977af36e668aa134c0ce55bab5bb15f to your computer and use it in GitHub Desktop.
Save dfkaye/e977af36e668aa134c0ce55bab5bb15f to your computer and use it in GitHub Desktop.
[ work in progress ] Test & polyfill-fix for JavaScript Number.toFixed() bugs; e.g., (1.015).toFixed(2) returns "1.01" instead of "1.02"

Number.toFixed() rounding errors: broken but fixable

wordpress post

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…

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 reports

There is a long history of bug reports with respect to rounding errors using toFixed().

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 certain size, 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, size}) {

  // Happy side-effect: `toString()` removes trailing zeroes.
  var f = fraction.toString()
  var fixed = f.split('.')[1].length - 1
  
  // All this to create the expectedFraction message...
  var last = Number(f.charAt(f.length - 1))
  var digit = Number(f.charAt(f.length - 2))

  last >= 5 && (digit = digit + 1)

  var expectedFraction = f.replace(/[\d]{2,2}$/, digit)

  return Array(size).fill(0)
    .map(function(v, i) {
      return i + 1
    })
    .filter(function(v) {
      // Compares 1.015 to 1.0151 b/c fixing by more than one decimal place rounds correctly.
      var n = v + Number(fraction) // number 1.015
      var s = n.toFixed(fixed) // string "1.015"
      var s1 = Number(n + '1').toFixed(fixed) // string "1.0151"
      return s != s1
    })
    .map(function(v, i) {
      return {
        given: (v + Number(fraction)).toString(),
        expected: (Number(v.toFixed(0)) + Number(expectedFraction)).toString(),
        actual: Number(v + fraction).toFixed(fixed)
      }
    })
}

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, size: 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:

  1. The last significant digit in the fraction must be 5 (.015 and .01500 produce the same result).
  2. The fixing length must shorten the fraction by only one digit.
  3. 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(p,f){
 f=p.toFixed, p.toFixed=function(d,s){
  s=(''+this).split('.')
  return f.call(+(!s[1]?s[0]:s.join('.')+'1'), d)
 }
}(Number.prototype));

Then run the test again and check that the length of the results is zero.

test({ fraction: .0015, size: 516 }) // Array []
test({ fraction: .0015, size: 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 :)

Polyfill update 2020

A reader, A. Shaw, discovered in 2019, that the polyfill above contained a flaw.

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(‘.’)))

Accordingly, the polyfill should read as follows:

(1.005).toFixed(2) == "1.01" || (function(prototype) {
  var F = prototype.toFixed;

  prototype.toFixed = toFixedPolyfill

  function toFixedPolyfill(fractionLength) {
    var split = this.toString().split('.');
    
    var value = !(1 in split)
      ? split[0]
      : split[1].length >= fractionLength
        ? split.join('.') + '1'
        : split.join('.')
        
    return F.call(+value, fractionLength);
  }
}(Number.prototype));

This contains easier-to-read implementations of the test and polyfill

test function

function test({fraction, upToValue}) {

  // 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(upToValue).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)
      }
    })
}

sample usage

test({fraction: 0.015 , upToValue: 128}).forEach(result => console.log(  result ) )

polyfill

(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));
/**
* 5 Dec 2017
*
* Fixes .xxx5 rounding problem by returning fix on .xxx51.
* Test against .005 to 2 places (always fails).
*/
;(1.005).toFixed(2)=="1.01"||(function(p,f){
f=p.toFixed, p.toFixed=function(d,s){
s=(''+this).split('.')
return f.call(+(!s[1]?s[0]:s.join('.')+'1'), d)
}
}(Number.prototype));
// before
(1.015).toFixed(2) // => "1.01"
// after
(1.015).toFixed(2) // => "1.02"
// before
(63.025).toFixed(2) // "63.02"
// after
(63.025).toFixed(2) // "63.03"
// finally using the test.js function
test({ fraction: .0015, size: 516 }).forEach(read); // should see no messages
/**
* 4,5 Dec 2017
*
* Detect rounding bug in Number(value).toFixed(fractionSize) for given value and
* fractionSize. Bug occurs inconsistently but mainly for fractions ending in 5 and
* to being "fixed" by only one decimal place - e.g., (1.015).toFixed(2) returns "1.01" but
* should return "1.02".
*
* How to use this function:
*
* 'test({ fraction: .015, size: 128 })` will process numbers 1.015 through 128.015 inclusive,
* and return an array of items with `given`, `expected`, and `actual` fields where the
* expected and actual values differ (i.e., where `toFixed()` returns an unexpected result.)
*
* You can process this output at the console with a `forEach` iterator as simple as
*
* test({ fraction: .015, size: 128 })
* .forEach(function(item) {
* console.log(item)
* })
*/
function test({fraction, size}) {
// Happy side-effect: `toString()` removes trailing zeroes.
var f = fraction.toString()
var fixed = f.split('.')[1].length - 1
// All this to create the expectedFraction message used in the map function below.
var last = Number(f.charAt(f.length - 1))
var digit = Number(f.charAt(f.length - 2))
last >= 5 && (digit = digit + 1)
var expectedFraction = f.replace(/[\d]{2,2}$/, digit)
return Array(size).fill(0)
.map(function(v, i) {
return i + 1
})
.filter(function(v) {
// Compares 1.015 to 1.0151 b/c fixing by more than one decimal place rounds correctly.
var n = v + Number(fraction) // number 1.015
var s = n.toFixed(fixed) // string "1.015"
var s1 = Number(n + '1').toFixed(fixed) // string "1.0151"
return s != s1
})
.map(function(v, i) {
return {
given: (v + Number(fraction)).toString(),
expected: (Number(v.toFixed(0)) + Number(expectedFraction)).toString(),
actual: Number(v + fraction).toFixed(fixed)
}
})
}
test({ fraction: .015, size: 128 })
.forEach(function(item) {
console.log(item)
})
// various other tests
test({ fraction: .14, size: 128 }) // 0 messages
test({ fraction: .15, size: 128 }) // 51 messages
test({ fraction: .16, size: 128 }) // 0 messages
test({ fraction: .25, size: 128 }) // 0 messages
test({ fraction: .015, size: 128 }) // 6 messages: expected 1.02 ; actual: 1.01
test({ fraction: .025, size: 128 }) // 51 messages: expected 63.03 ; actual: 63.02
test({ fraction: .0015, size: 516 }) // 197 messages: expected 516.002 ; actual: 516.001
test({ fraction: .0025, size: 516 }) // 491 messages: expected 511.003 ; actual: 511.002
// handle results with forEach reader:
function read(item) {
console.log('given:', item.given, 'expected:', item.expected, 'actual:', item.actual)
}
test({ fraction: .15, size: 128 }).forEach(read); // should see 51 messages like "given: 0.15 expected: 63.2 actual: 63.1"
test({ fraction: .015, size: 128 }).forEach(read); // should see 6 messages like "given: 0.015 expected: 63.2 actual: 63.1"
test({ fraction: .0015, size: 516 }).forEach(read); // should see 197 messages like "given: 0.0015 expected: 128.2 actual: 128.001"
/**
* even-odd tests suggested by @zzzbov
* results are hardly consistent
*/
test({ fraction: .045, size: 32 }).forEach(read)
// given: 0.045 expected: 1.05 actual: 1.04
// 1 - 15 NOT OK
// 16 - 128 OK
test({ fraction: .055, size: 128 }).forEach(read)
// given: 0.055 expected: 1.06 actual: 1.05
// 1 NOT OK
// 2 - 3 OK
// 4 - 63 NOT OK
// 64 - 128 OK
test({ fraction: .065, size: 128 }).forEach(read)
// given: 0.065 expected: 1.07 actual: 1.06
// 1 - 3 NOT OK
// 4 - 7 OK
// 8 - 15 NOT OK
// 16 - 31 OK
// 32 - 128 NO OK
test({ fraction: .075, size: 32 }).forEach(read)
// given: 0.075 expected: 1.08 actual: 1.07
// 2 - 7 OK
// 8 - 31 NOT OK
// 32 - 128 OK
test({ fraction: .085, size: 128 }).forEach(read)
// given: 0.085 expected: 7.09 actual: 7.08
// Only 8 - 63 OK
test({ fraction: .095, size: 128 }).forEach(read)
// given: 0.095 expected: 1.1 actual: 1.09
// 1 NOT OK
// 2 - 3 OK
// 4 - 7 NOT OK
// 8 - 15 OK
// 16 - 128 NOT OK
@markfilan
Copy link

It seems like toFixed() is a better solution, but it is not! In some cases it will NOT round correctly. Also, Math.round() will NOT round correctly in some cases.

To correct the rounding problem with the previous Math.round() and toFixed(), you can define a custom JavaScript rounding function that performs a "nearly equal" test to determine whether a fractional value is sufficiently close to a midpoint value to be subject to midpoint rounding. The following function return the value of the given number rounded to the nearest integer accurately.

Number.prototype.roundTo = function(decimal) {
  return +(Math.round(this + "e+" + decimal)  + "e-" + decimal);
}

var num = 9.7654;
console.log( num.roundTo(2)); //output 9.77

@dfkaye
Copy link
Author

dfkaye commented Apr 26, 2022

thanks, @markfilan

My gist is a bit out of date, and so is my Wordpress post. You can read a 2021 version that contains another solution I found on stackoverflow using Number.toLocaleString() (which uses Intl.NumberFormat()).

As for your solution, it is definitely more concise - and more portable. Would it be OK with you if I add it to the toFixed post?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment