Rounding JavaScript decimals

Posted on

Problem

JavaScript has some inaccurate rounding behavior so I’ve been looking for a reliable solution without success. There are many answers in this SO post but none cover all the edge cases as far as I can tell. I wrote the following which handles all the edge cases presented. Will it be reliable with edge cases I haven’t tested?

If this is a viable solution, any enhancements to make it more efficient would be appreciated. It’s not fast (time to run function 1000000 times: 778ms) but doesn’t seem to be terrible either. If there is a better solution, please post.

The edge cases that seemed to give the most problem were the first two:

console.log(round(1.005, 2)); // 1.01
console.log(round(1234.00000254495, 10)); //1234.000002545
console.log(round(1835.665, 2)); // 1835.67))
console.log(round(-1835.665, 2)); // -1835.67))
console.log(round(10.8034, 2)); // 10.8
console.log(round(1.275, 2)); // 1.28
console.log(round(1.27499, 2)); // 1.27
console.log(round(1.2345678e+2, 2)); // 123.46
console.log(round(1234.5678, -1)); // 1230
console.log(round(1235.5678, -1)); // 1240
console.log(round(1234.5678, -2)); // 1200
console.log(round(1254.5678, -2)); // 1300
console.log(round(1254, 2)); // 1254
console.log(round("123.45")); // 123
console.log(round("123.55")); // 124

function round(number, precision) {
  precision = precision ? precision : 0;

  var sNumber = "" + number;
  var a = sNumber.split(".");
  if (a.length == 1 || precision < 0) {
    // from MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
    var factor = Math.pow(10, precision);
    var tempNumber = number * factor;
    var roundedTempNumber = Math.round(tempNumber);
    return roundedTempNumber / factor;
  }
  // use one decimal place beyond the precision
  var factor = Math.pow(10, precision + 1);
  // separate out decimals and trim or pad as necessary
  var sDec = a[1].substr(0, precision + 1);
  if (sDec.length < precision + 1) {
    for (var i = 0; i < (precision - sDec.length); i++)
      sDec = sDec.concat("0");
  }
  // put the number back together
  var sNumber = a[0] + "." + sDec;
  var number = parseFloat(sNumber);
  // test the last digit
  var last = sDec.substr(sDec.length - 1);
  if (last >= 5) {
    // round up by correcting float error
    // UPDATED - for negative numbers will round away from 0 
    // e.g. round(-2.5, 0) == -3        
    number += 1/(factor) * (+number < 0 ? -1 : 1);
  }
  number = +number.toFixed(precision);
  return number;
};

Solution

Here is my solution that I came up with by starting off from your own code, which as I was tracing the logic I realized was a bit bloated, through quite efficient. What’s more, when I was adding a few more test cases I discovered 2 bugs in it. I also did benchmark of your, @mseifert and mine code.

But first, few comments about your code:

  • You have quite numerous unnecessary (in my opinion) variables,
  • The loop and surrounding conditional statement is completely unnecessary as well,
  • Variables’ names could be more descriptive, especially a‘s,
  • sDec += '0' is shorter and faster than sDec = sDec.concat("0"),
  • Why to assign +number.toFixed(precision) to variable number, if in the very next line you just return number? Go with return +number.toFixed(precision) instead.

Test cases

Just one note: this is definitely not the elegant way of writing tests, but test cases themselves weren’t in the scope of this question, so I allowed myself to do it this way.

Two lines with comments next to them are those which revealed bugs in your original code to me.

console.clear();
console.log(round(1.0e-5, 5) === 0.00001);
console.log(round(1.0e-20, 20) === 1e-20);
console.log(round(1.0e20, 2) === 100000000000000000000);
console.log(round(1.005, 2) === 1.01);
console.log(round(1234.00000254495, 10) === 1234.000002545);
console.log(round(1835.665, 2) === 1835.67);
console.log(round(-1835.665, 2) === -1835.67);
console.log(round(1.27499, 2) === 1.27);
console.log(round(1.2345678e+2, 2) === 123.46);
console.log(round(1234.5678, -1) === 1230);
console.log(round(1234.5678, -2) === 1200);
console.log(round(1254.5678, -2) === 1300);
console.log(round(1254, 2) === 1254);
console.log(round(1254) === 1254);
console.log(round('1254') === 1254);
console.log(round('123.55') === 124);
console.log(round('123.55', 1) === 123.6);
console.log(round('123.55', '1') === 123.6);
console.log(round(123.55, '1') === 123.6);
console.log(round('-1835.665', 2) === -1835.67);
console.log(round('-1835.665', '2') === -1835.67); // Made me turn precision into +precision
console.log(round(-1835.665, '2') === -1835.67);
console.log(round('1.0e-5', 5) === 0.00001); // Made me add number = +number;
console.log(round('1.0e-5', '5') === 0.00001);
console.log(round(1.0e-5, '5') === 0.00001);
console.log(round('1.0e-20', 20) === 1e-20);
console.log(round('1.0e-20', '20') === 1e-20);
console.log(round(1.0e-20, '20') === 1e-20);
console.log(round('1.0e20', 2) === 100000000000000000000);
console.log(round('1.0e20', '2') === 100000000000000000000);
console.log(round(1.0e20, '2') === 100000000000000000000);

Benchmark

Notice that my code is not much faster than yours.

(made with JSBench.Me and using Chrome 57)

Benchmark

Complete code

You can change the only var keyword to const from ES6 if you want to use it, since the variables that this keyword applies to are indeed constant.

function round(number, precision) {
    'use strict';
    precision = precision ? +precision : 0;

    var sNumber     = number + '',
        periodIndex = sNumber.indexOf('.'),
        factor      = Math.pow(10, precision);

    if (periodIndex === -1 || precision < 0) {
        return Math.round(number * factor) / factor;
    }

    number = +number;

    // sNumber[periodIndex + precision + 1] is the last digit
    if (sNumber[periodIndex + precision + 1] >= 5) {
        // Correcting float error
        // factor * 10 to use one decimal place beyond the precision
        number += (number < 0 ? -1 : 1) / (factor * 10);
    }

    return +number.toFixed(precision);
}

You should automate your test cases by comparing the actual with the expected result and only logging it when these differ. This will tell you immediately whether everything worked (no logging at all).

Your test doesn’t cover numbers whose string representation contains an e.

Here is a solution which is quick and handles all cases – negative number and negative precision. It was adapted from MDN – the main differences are

  1. I pass positive numbers to specify decimal places where it is the opposite on MDN.
  2. I took it out of the prototype and limited it to just the round function (removed ceil and floor)
  3. it is significantly faster
function round(number, precision) {
  number = +number;
  precision = precision ? +precision : 0;
  if (precision == 0) {
    return Math.round(number);
  }
  var sign = 1;
  if (number < 0) {
    sign = -1;
    number = Math.abs(number);
  }

  // Shift
  number = number.toString().split('e');
  number = Math.round(+(number[0] + 'e' + (number[1] ? (+number[1] + precision) : precision)));
  // Shift back
  number = number.toString().split('e');
  return +(number[0] + 'e' + (number[1] ? (+number[1] - precision) : -precision)) * sign;
}

console.log(round(1.0e-5, 5)); // 0.00001
console.log(round(1.0e-20, 20)); // 1e-20
console.log(round(1.0e20, 2)); // 100000000000000000000
console.log(round(1.005, 2)); // 1.01
console.log(round(1234.00000254495, 10)); //1234.000002545
console.log(round(1835.665, 2)); // 1835.67))
console.log(round(-1835.665, 2)); // -1835.67))
console.log(round(1.27499, 2)); // 1.27
console.log(round(1.2345678e+2, 2)); // 123.46
console.log(round(1234.5678, -1)); // 1230
console.log(round(1234.5678, -2)); // 1200
console.log(round(1254.5678, -2)); // 1300
console.log(round(1254, 2)); // 1254
console.log(round("123.55")); // 124

Use this function to round the given number

Number((6.688689).toFixed(1));

Leave a Reply

Your email address will not be published. Required fields are marked *