Is there a reliable way in JavaScript to obtain the number of decimal places of an arbitrary number?

20,113

Solution 1

Historical note: the comment thread below may refer to first and second implementations. I swapped the order in September 2017 since leading with a buggy implementation caused confusion.

If you want something that maps "0.1e-100" to 101, then you can try something like

function decimalPlaces(n) {
  // Make sure it is a number and use the builtin number -> string.
  var s = "" + (+n);
  // Pull out the fraction and the exponent.
  var match = /(?:\.(\d+))?(?:[eE]([+\-]?\d+))?$/.exec(s);
  // NaN or Infinity or integer.
  // We arbitrarily decide that Infinity is integral.
  if (!match) { return 0; }
  // Count the number of digits in the fraction and subtract the
  // exponent to simulate moving the decimal point left by exponent places.
  // 1.234e+2 has 1 fraction digit and '234'.length -  2 == 1
  // 1.234e-2 has 5 fraction digit and '234'.length - -2 == 5
  return Math.max(
      0,  // lower limit.
      (match[1] == '0' ? 0 : (match[1] || '').length)  // fraction length
      - (match[2] || 0));  // exponent
}

According to the spec, any solution based on the builtin number->string conversion can only be accurate to 21 places beyond the exponent.

9.8.1 ToString Applied to the Number Type

  1. Otherwise, let n, k, and s be integers such that k ≥ 1, 10k−1 ≤ s < 10k, the Number value for s × 10n−k is m, and k is as small as possible. Note that k is the number of digits in the decimal representation of s, that s is not divisible by 10, and that the least significant digit of s is not necessarily uniquely determined by these criteria.
  2. If k ≤ n ≤ 21, return the String consisting of the k digits of the decimal representation of s (in order, with no leading zeroes), followed by n−k occurrences of the character ‘0’.
  3. If 0 < n ≤ 21, return the String consisting of the most significant n digits of the decimal representation of s, followed by a decimal point ‘.’, followed by the remaining k−n digits of the decimal representation of s.
  4. If −6 < n ≤ 0, return the String consisting of the character ‘0’, followed by a decimal point ‘.’, followed by −n occurrences of the character ‘0’, followed by the k digits of the decimal representation of s.

Historical note: The implementation below is problematic. I leave it here as context for the comment thread.

Based on the definition of Number.prototype.toFixed, it seems like the following should work but due to the IEEE-754 representation of double values, certain numbers will produce false results. For example, decimalPlaces(0.123) will return 20.

function decimalPlaces(number) {
  // toFixed produces a fixed representation accurate to 20 decimal places
  // without an exponent.
  // The ^-?\d*\. strips off any sign, integer portion, and decimal point
  // leaving only the decimal fraction.
  // The 0+$ strips off any trailing zeroes.
  return ((+number).toFixed(20)).replace(/^-?\d*\.?|0+$/g, '').length;
}

// The OP's examples:
console.log(decimalPlaces(5555.0));  // 0
console.log(decimalPlaces(5555));  // 0
console.log(decimalPlaces(555.5));  // 1
console.log(decimalPlaces(555.50));  // 1
console.log(decimalPlaces(0.0000005));  // 7
console.log(decimalPlaces(5e-7));  // 7
console.log(decimalPlaces(0.00000055));  // 8
console.log(decimalPlaces(5e-8));  // 8
console.log(decimalPlaces(0.123));  // 20 (!)

Solution 2

Well, I use a solution based on the fact that if you multiply a floating-point number by the right power of 10, you get an integer.

For instance, if you multiply 3.14 * 10 ^ 2, you get 314 (an integer). The exponent represents then the number of decimals the floating-point number has.

So, I thought that if I gradually multiply a floating-point by increasing powers of 10, you eventually arrive to the solution.

let decimalPlaces = function () {
   function isInt(n) {
      return typeof n === 'number' && 
             parseFloat(n) == parseInt(n, 10) && !isNaN(n);
   }
   return function (n) {
      const a = Math.abs(n);
      let c = a, count = 1;
      while (!isInt(c) && isFinite(c)) {
         c = a * Math.pow(10, count++);
      }
      return count - 1;
   };
}();

for (const x of [
  0.0028, 0.0029, 0.0408,
  0, 1.0, 1.00, 0.123, 1e-3,
  3.14, 2.e-3, 2.e-14, -3.14e-21,
  5555.0, 5555, 555.5, 555.50, 0.0000005, 5e-7, 0.00000055, 5e-8,
  0.000006, 0.0000007,
  0.123, 0.121, 0.1215
]) console.log(x, '->', decimalPlaces(x));

Solution 3

2017 Update

Here's a simplified version based on Edwin's answer. It has a test suite and returns the correct number of decimals for corner cases including NaN, Infinity, exponent notations, and numbers with problematic representations of their successive fractions, such as 0.0029 or 0.0408. This covers the vast majority of financial applications, where 0.0408 having 4 decimals (not 6) is more important than 3.14e-21 having 23.

function decimalPlaces(n) {
  function hasFraction(n) {
    return Math.abs(Math.round(n) - n) > 1e-10;
  }

  let count = 0;
  // multiply by increasing powers of 10 until the fractional part is ~ 0
  while (hasFraction(n * (10 ** count)) && isFinite(10 ** count))
    count++;
  return count;
}

for (const x of [
  0.0028, 0.0029, 0.0408, 0.1584, 4.3573, // corner cases against Edwin's answer
  11.6894,
  0, 1.0, 1.00, 0.123, 1e-3, -1e2, -1e-2, -0.1,
  NaN, 1E500, Infinity, Math.PI, 1/3,
  3.14, 2.e-3, 2.e-14,
  1e-9,  // 9
  1e-10,  // should be 10, but is below the precision limit
  -3.14e-13,  // 15
  3.e-13,  // 13
  3.e-14,  // should be 14, but is below the precision limit
  123.12345678901234567890,  // 14, the precision limit
  5555.0, 5555, 555.5, 555.50, 0.0000005, 5e-7, 0.00000055, 5e-8,
  0.000006, 0.0000007,
  0.123, 0.121, 0.1215
]) console.log(x, '->', decimalPlaces(x));

The tradeoff is that the method is limited to maximum 10 guaranteed decimals. It may return more decimals correctly, but don't rely on that. Numbers smaller than 1e-10 may be considered zero, and the function will return 0. That particular value was chosen to solve correctly the 11.6894 corner case, for which the simple method of multiplying by powers of 10 fails (it returns 5 instead of 4).

However, this is the 5th corner case I've discovered, after 0.0029, 0.0408, 0.1584 and 4.3573. After each, I had to reduce the precision by one decimal. I don't know if there are other numbers with less than 10 decimals for which this function may return an incorrect number of decimals. To be on the safe side, look for an arbitrary precision library.

Note that converting to string and splitting by . is only a solution for up to 7 decimals. String(0.0000007) === "7e-7". Or maybe even less? Floating point representation isn't intuitive.

Solution 4

Simple "One-Liner":

If what you're doing requires more than 16 digit precision, then this is not for you.

This 'one-liner' will work fine for the other 99.99999999999999% of the time. (Yes, even that number.)😜

function numDec(n){return n%1==0?0:(""+n).length-(""+n).lastIndexOf(".")-1}

Demo in the snippet:

function numDec(n){return n%1==0?0:(""+n).length-(""+n).lastIndexOf(".")-1}


setInterval(function(){
  n=Math.random()*10000000000;
  document.body.innerHTML=n+' ← '+numDec(n)+' decimal places';
},777);
body{font-size:123.4567890%; font-family:'fira code';}

More info:

Solution 5

This works for me

const decimalPlaces = value.substring(value.indexOf('.') + 1).length;

This method expects the value to be a standard number.

Share:
20,113
Milosz
Author by

Milosz

An all-around decent guy.

Updated on September 28, 2021

Comments

  • Milosz
    Milosz over 2 years

    It's important to note that I'm not looking for a rounding function. I am looking for a function that returns the number of decimal places in an arbitrary number's simplified decimal representation. That is, we have the following:

    decimalPlaces(5555.0);     //=> 0
    decimalPlaces(5555);       //=> 0
    decimalPlaces(555.5);      //=> 1
    decimalPlaces(555.50);     //=> 1
    decimalPlaces(0.0000005);  //=> 7
    decimalPlaces(5e-7);       //=> 7
    decimalPlaces(0.00000055); //=> 8
    decimalPlaces(5.5e-7);     //=> 8
    

    My first instinct was to use the string representations: split on '.', then on 'e-', and do the math, like so (the example is verbose):

    function decimalPlaces(number) {
      var parts = number.toString().split('.', 2),
        integerPart = parts[0],
        decimalPart = parts[1],
        exponentPart;
    
      if (integerPart.charAt(0) === '-') {
        integerPart = integerPart.substring(1);
      }
    
      if (decimalPart !== undefined) {
        parts = decimalPart.split('e-', 2);
        decimalPart = parts[0];
      }
      else {
        parts = integerPart.split('e-', 2);
        integerPart = parts[0];
      }
      exponentPart = parts[1];
    
      if (exponentPart !== undefined) {
        return integerPart.length +
          (decimalPart !== undefined ? decimalPart.length : 0) - 1 +
          parseInt(exponentPart);
      }
      else {
        return decimalPart !== undefined ? decimalPart.length : 0;
      }
    }
    

    For my examples above, this function works. However, I'm not satisfied until I've tested every possible value, so I busted out Number.MIN_VALUE.

    Number.MIN_VALUE;                      //=> 5e-324
    decimalPlaces(Number.MIN_VALUE);       //=> 324
    
    Number.MIN_VALUE * 100;                //=> 4.94e-322
    decimalPlaces(Number.MIN_VALUE * 100); //=> 324
    

    This looked reasonable at first, but then on a double take I realized that 5e-324 * 10 should be 5e-323! And then it hit me: I'm dealing with the effects of quantization of very small numbers. Not only are numbers being quantized before storage; additionally, some numbers stored in binary have unreasonably long decimal representations, so their decimal representations are being truncated. This is unfortunate for me, because it means that I can't get at their true decimal precision using their string representations.

    So I come to you, StackOverflow community. Does anyone among you know a reliable way to get at a number's true post-decimal-point precision?

    The purpose of this function, should anyone ask, is for use in another function that converts a float into a simplified fraction (that is, it returns the relatively coprime integer numerator and nonzero natural denominator). The only missing piece in this outer function is a reliable way to determine the number of decimal places in the float so I can multiply it by the appropriate power of 10. Hopefully I'm overthinking it.

  • Milosz
    Milosz about 12 years
    This reminds me that I forgot to include checking for minus signs at the beginning of the number. Thanks! I also enjoy how concise this function is. Pushing the number past 1 in absolute value to avoid the negative exponent is clever (though I don't understand why JavaScript switches to exponent notation so much later for positive exponents). If nothing that works past e-17 comes along, this is probably what I'll go with.
  • Milosz
    Milosz about 12 years
    Very informative, thank you. I think I might end up using toFixed(20) for simplicity's sake and ignore the really really small numbers. The quantization line has to be drawn somewhere, and 20 isn't low enough to be annoying for most purposes.
  • Mike Samuel
    Mike Samuel about 12 years
    @Milosz, Yeah. If I was writing a library, I would go with the second approach, but for specific applications, the first may be sufficient. One thing to note though is that the first doesn't deal as well with NaN and ±Infinity.
  • user1
    user1 over 10 years
    This isn't working for me in Google Chrome version 30. Input of 555.50 results in an output of 1, but 555.20 results in an output of 20.
  • ARF
    ARF over 10 years
    One would think this would be easier. On the other hand there is no problem in the world that a big enough regular expression can not solve. +1 for using MDN instead of w3schools
  • ARF
    ARF over 10 years
    I did some testing ((+number).toFixed(20)) with number= 0.2 is returning "0.20000000000000001110" which screws up your method. I tested on chrome.
  • ARF
    ARF over 10 years
    As of the last edit (April 1, 2013) on this answer it return (correctly) 1 for 1.2. It howerver returns 1 (instead of zero) for numbers with no decimal places
  • ARF
    ARF over 10 years
    My answer fixes this problem I found. I'm not sure why you are checking the chartAt(0) for the minus sign since you can just ignore it on the regex analyzing only the part after the '.' character.
  • Mike Samuel
    Mike Samuel over 10 years
    0.2 is not representable exactly in binary, and the decimal value that is closest to the IEEE-754 double value that is closest to 0.2 does have that many decimal places. I can only reason about the double values that reach my code, so I think the disconnect here is in the loss of precision due to the translation from a semantically meaningful "0.2" to an IEEE-754 value.
  • Daniël Tulp
    Daniël Tulp over 10 years
    +1 for not using reqular expressions and for a solution that is not limited to any specific number of decimals. Only problem I see is numbers with an infinite number of decimals like 1/3.
  • Edwin Dalorzo
    Edwin Dalorzo almost 10 years
    @DaniëlTulp Even numbers with infinite decimal notation must represented with a finite number of decimals in computer memory. I guess this approach would tell you how many decimals that finite notation has. I suppose that to accurately represented 1/3 we would have to use fractions, since the decimal notation wouldn't work.
  • mswieboda
    mswieboda almost 9 years
    unfortunately, this loss of precision makes the toFixed(20) solution pretty unreliable, since I'm getting a fairly consistent 20 for numbers like 0.123, 0.121, 0.1215 etc. Although I've only tested in Chrome
  • mswieboda
    mswieboda almost 9 years
    this has been the most consistent approach for me. I feel more comfortable using math then converting to strings, props!
  • Mike Samuel
    Mike Samuel almost 9 years
    @SuckerForMayhem, try the second one. I get 3,3,4 for those inputs.
  • Dan Dascalescu
    Dan Dascalescu almost 7 years
    I get 19 for 1.31606 due to how V8 represents the numbers. Tested in Chrome and Node. This is just not a reliable solution.
  • Dan Dascalescu
    Dan Dascalescu almost 7 years
    It works without errors only for the very few test cases you've included :)
  • Dan Dascalescu
    Dan Dascalescu almost 7 years
    It fails for 0.0029, returning 5 instead of 4. Floating point in JavaScript is a minefield.
  • khodayar J
    khodayar J over 6 years
    what about the languages that separate the decimal part with ',' ?
  • Mike Samuel
    Mike Samuel over 6 years
    @DanDascalescu, try the second one. decimalPlaces('1.31606') === 5
  • Mike Samuel
    Mike Samuel over 6 years
    @DanDascalescu, I just reordered things to avoid confusion. The second implementation is now first.
  • Mike Samuel
    Mike Samuel over 6 years
    @khodayarJ, In human locales, commas are used as decimal separators in many locales and digit group separators in others. This function is meant to operate on numbers formatted per JavaScript rules for parsing by machines.
  • Hanna
    Hanna over 6 years
    Likewise, it returns 6 for 0.0408 instead of 4. Seems like there isn't really a straight-forward solution without any gotchas.
  • Hanna
    Hanna over 6 years
    +1 Although this function has known limitations, it works for what I need it (any reasonable amount of decimals for human consumption, namely 0 - 4 in my app)
  • daniol
    daniol about 4 years
    This function isn't type complaint as match[2] is a string which is converted into a number without parseInt()
  • Mike Samuel
    Mike Samuel about 4 years
    @daniol, What problems do you foresee? match[2] is the right operand of - which coerces its operands to numbers. ES262 § 7.1.3.1 mentions OctalLiteral but in my testing 0 - "012" === 12 parses as decimal.