Number().toFixed() Rounding Errors: Broken But Fixable

Share this article

Number().toFixed() Rounding Errors: Broken But Fixable

This article was originally published on David Kaye.

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. I had missed it until December 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().

Here is a short sample of StackOverflow questions about this problem:

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:

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

Frequently Asked Questions (FAQs) about JavaScript’s Number.toFixed() Method

What is the purpose of the Number.toFixed() method in JavaScript?

The Number.toFixed() method in JavaScript is used to format a number using fixed-point notation. It returns a string representation of a number that does not use exponential notation and has exactly a specified number of digits after the decimal place. The number is rounded if necessary, and the resulting string is of a specific length after the decimal point.

Why does the Number.toFixed() method sometimes give inaccurate results?

The Number.toFixed() method can sometimes give inaccurate results due to the way JavaScript handles binary floating-point numbers. JavaScript uses binary floating-point numbers which can’t accurately represent all decimal fractions. When a number is rounded to a specific decimal place using the Number.toFixed() method, this inaccuracy can lead to unexpected results.

How can I fix the rounding errors in the Number.toFixed() method?

One way to fix the rounding errors in the Number.toFixed() method is by using a custom rounding function. This function can take into account the specificities of JavaScript’s number handling and provide more accurate results. For example, you can use a function that multiplies the number by a power of 10, rounds it to the nearest whole number, and then divides it by the same power of 10.

Can I use the Number.toFixed() method with non-numeric values?

No, the Number.toFixed() method can only be used with numeric values. If you try to use it with a non-numeric value, JavaScript will throw a TypeError. If you need to use this method with a value that might not be a number, you should first check if the value is numeric.

Is there a performance difference between the Number.toFixed() method and other rounding methods?

The performance difference between the Number.toFixed() method and other rounding methods is usually negligible. However, if you’re performing a large number of operations, using a custom rounding function might be slightly faster than using the Number.toFixed() method.

Can I use the Number.toFixed() method to round to more than 20 decimal places?

No, the Number.toFixed() method can only round to up to 20 decimal places. If you try to round to more than 20 decimal places, JavaScript will throw a RangeError.

How does the Number.toFixed() method handle negative numbers?

The Number.toFixed() method handles negative numbers in the same way as positive numbers. It rounds the absolute value of the number to the specified number of decimal places and then adds the negative sign back.

Can I use the Number.toFixed() method in all JavaScript environments?

Yes, the Number.toFixed() method is a standard part of JavaScript and should be available in all JavaScript environments. However, due to differences in how different JavaScript engines handle numbers, the results might not be exactly the same in all environments.

What happens if I don’t pass any arguments to the Number.toFixed() method?

If you don’t pass any arguments to the Number.toFixed() method, it will default to rounding to 0 decimal places. This means it will round the number to the nearest whole number.

Can I use the Number.toFixed() method with large numbers?

Yes, you can use the Number.toFixed() method with large numbers. However, keep in mind that JavaScript can only accurately represent numbers up to 2^53 – 1. If you try to use the Number.toFixed() method with a number larger than this, it might not give accurate results.

David KayeDavid Kaye
View Author

David Kaye has been a front-end developer since 1999, deeply interested in JavaScript, and (since 2006) test-driven development. He has worked at large firms such as Charles Schwab, The Gap, and Blue Shield of California, as well as smaller startup-sized companies including Glassdoor.

bugjavascriptjoelfmath
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week