bignumber.js icon indicating copy to clipboard operation
bignumber.js copied to clipboard

Format to at least N decimal places

Open jackc opened this issue 5 years ago • 4 comments

It is sometimes convenient when working with money to always format to at least N decimal places, but not to round if there is more precision present. For example, US currency generally should be formatted to two decimal places, but if there are fractions of a cent they should be included not rounded:

(new BigNumber("1")).toFormat(2) // 1.00 - toFormat(2) will work correctly without fractions of a cent
(new BigNumber("1.234")).toFormat(2) // 1.23 - toFormat(2) loses fractional cents

I currently work around this as follows:

export const formatMinDP = function(n, dp) {
  const a = n.toFormat(dp)
  const b = n.toFormat()
  if (a.length > b.length) {
    return a
  } else {
    return b
  }
}

But it seems this would a common enough use that it might be useful to have built-in. Not sure what sort of interface would make sense. Maybe a no-op rounding mode or a new format key to skip rounding.

jackc avatar Apr 24 '20 16:04 jackc

Thanks for your input. Yes, I agree.

One option is to change toFormat so a maximum or minimum number of decimal places can be specified via an options object (which could also allow all the other format options to be passed in as well).

In the meantime, the following overwrites the toFormat method so it can take an object specifying either decimalPlaces, maximumDecimalPlaces or minimumDecimalPlaces.

BigNumber.prototype.toFormat = (function (u) {
  const format = BigNumber.prototype.toFormat;
  return function (dp, rm) {
    if (typeof dp === 'object' && dp) {
      let t = dp.minimumDecimalPlaces;
      if (t !== u) return format.call(this, this.dp() < t ? t : u);
      rm = dp.roundingMode;      
      t = dp.maximumDecimalPlaces;
      if (t !== u) return format.call(this.dp(t, rm));
      t = dp.decimalPlaces;
      if (t !== u) return format.call(this, t, rm);
    } 
    return format.call(this, dp, rm);
  }  
})();

Usage:

const x = new BigNumber('1');

x.toFormat(2);                                      // '1.00'
x.toFormat({ decimalPlaces: 2 });                   // '1.00'
x.toFormat({ maximumDecimalPlaces: 2 });            // '1'
x.toFormat({ minimumDecimalPlaces: 2 });            // '1.00'

const y = new BigNumber('1.234');

y.toFormat(2);                                      // '1.23'
y.toFormat({ decimalPlaces: 2 });                   // '1.23'
y.toFormat({ decimalPlaces: 2, roundingMode: 0 });  // '1.24'
y.toFormat({ maximumDecimalPlaces: 2 });            // '1.23'
y.toFormat({ minimumDecimalPlaces: 2 });            // '1.234'

By the way, using the dp() method would make your workaround more efficient:

const formatMinDP = function(n, dp) {
  return n.dp() < dp ? n.toFormat() : n.toFormat(dp);
}

MikeMcl avatar Apr 25 '20 11:04 MikeMcl

Thanks! That's great!

jackc avatar Apr 26 '20 13:04 jackc

@MikeMcl Thanks. This is really helpful. I would just suggest to add a preserveTrailingZeroes key which automatically preserves all trailing zeroes 0s from the original value.

Edit: @MikeMcl decimalSeparator & groupSeparator options doesn't work when decimalPlaces, maximumDecimalPlaces or minimumDecimalPlaces options are provided.

rupak-nm avatar Apr 07 '22 03:04 rupak-nm

I had the same use case and added a .toFormat2 method:

      FORMAT = {
        minDP: 0,
        maxDP: DECIMAL_PLACES,
        roundingMode: ROUNDING_MODE,
        prefix: '',
        groupSize: 3,
        secondaryGroupSize: 0,
        groupSeparator: ',',
        decimalSeparator: '.',
        fractionGroupSize: 0,
        fractionGroupSeparator: '\xA0',        // non-breaking space
        suffix: ''
      }

      ....

    P.toFormat2 = function ( format ) {
      var str,i,
        x = this;

      if (format == null ) {
        format = FORMAT;
      } else if (typeof format != 'object') {
        throw Error
          (bignumberError + 'Argument not an object: ' + format);
      }
      
      for ( i in FORMAT ) {
          if ( typeof format[i] === 'undefined' ) {
              format[i] = FORMAT[i];
          }
      }

      str = x.toFixed( format.maxDP, format.roundingMode );
      
      if (x.c) {
        var arr = str.split('.'),
          g1 = +format.groupSize,
          g2 = +format.secondaryGroupSize,
          groupSeparator = format.groupSeparator || '',
          intPart = arr[0],
          fractionPart = arr[1],
          isNeg = x.s < 0,
          intDigits = isNeg ? intPart.slice(1) : intPart,
          len = intDigits.length;

        if (g2) {
          i = g1;
          g1 = g2;
          g2 = i;
          len -= i;
        }

        // intPart
        if (g1 > 0 && len > 0) {
          i = len % g1 || g1;
          intPart = intDigits.substr(0, i);
          for (; i < len; i += g1) intPart += groupSeparator + intDigits.substr(i, g1);
          if (g2 > 0) intPart += groupSeparator + intDigits.slice(i);
          if (isNeg) intPart = '-' + intPart;
        }
        
        // fractionPart
        if ( fractionPart && fractionPart.length > format.minDP ) {
            // trim trailing zeros
            while ( fractionPart.length > format.minDP && fractionPart.slice(-1) === '0' ) {
                fractionPart = fractionPart.slice(0, -1);
            }
        }

        str = fractionPart
         ? intPart + (format.decimalSeparator || '') + ((g2 = +format.fractionGroupSize)
          ? fractionPart.replace(new RegExp('\\d{' + g2 + '}\\B', 'g'),
           '$&' + (format.fractionGroupSeparator || ''))
          : fractionPart)
         : intPart;
      }

      return (format.prefix || '') + str + (format.suffix || '');
    };

I merged the dp und rm parameters into the format object, since these are as important as any other format property imho. I'd appreciate it if you could add this or something equally usable to your code. Minimal decimal places are often important, even if it's zeros.

piecler avatar Jul 27 '23 15:07 piecler