Ryan
Ryan

Reputation: 24035

How to divide numbers in JavaScript to arbitrary precision (e.g. 28 decimal places)

I'm aware of https://floating-point-gui.de/ and the fact that there are many libraries available to help with big numbers, but I've surprisingly been unable to find anything that handles more than 19 decimal places in the result of a division operation.

I've spent hours trying libraries such as exact-math, decimal.js, bignumber.js, and others.

How would you handle the case below marked with ⭐?

// https://jestjs.io/docs/getting-started#using-typescript

import exactMath from 'exact-math'; // https://www.npmjs.com/package/exact-math
import { Decimal } from 'decimal.js'; // https://github.com/MikeMcl/decimal.js

const testCases = [
  // The following cases work in exact-math but fail in Decimal.js:
  '9999513263875304671192000009',
  '4513263875304671192000009',
  '530467119.530467119',
  // The following cases fail in both Decimal.js and exact-math:
  '1.1998679030467029262556391239', // ⭐ exact-math rounds these 28 decimal places to 17: "1.1998679030467029263000000000"
];

describe('decimals.js', () => {
  testCases.forEach((testCase) => {
    test(testCase, () => {
      expect(new Decimal(testCase).div(new Decimal(1)).toFixed(28)).toBe(testCase); // Dividing by 1 (very simple!)
    });
  });
});

describe('exact-math', () => {
  testCases.forEach((testCase) => {
    test(testCase, () => {
      expect(exactMath.div(testCase, 1, { returnString: true })).toBe(testCase); // Dividing by 1 (very simple!)
    });
  });
});

Upvotes: 2

Views: 299

Answers (1)

Ryan
Ryan

Reputation: 24035

Some of my test cases above didn't make sense (since I shouldn't have expected the outputs to equal the inputs since I was using .toFixed().

Then the real answer was suggested by @James: use the maxDecimal option: https://www.npmjs.com/package/exact-math#the-config-maxdecimal-property-usage Or https://mikemcl.github.io/decimal.js/#precision in decimal.js.

See the line with ⭐ below.

import exactMath from 'exact-math'; // https://www.npmjs.com/package/exact-math
import { Decimal } from 'decimal.js'; // https://github.com/MikeMcl/decimal.js

/**
 *
 * @param amount {string}
 * @param decimals {number} e.g. 6 would return 6 decimal places like 0.000000
 * @param locale {string} e.g. 'en-US' or 'de-DE'
 * @returns {string} e.g. 1,000.000000
 */
export function getLocaleStringToDecimals(amount: string, decimals: any, locale?: string): string {
  // Thanks to https://stackoverflow.com/a/68906367/ because https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString and https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt/toLocaleString would not work for huge numbers or numbers with many decimal places.

  const decimalFormat = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  const decimalFullString = '1.1';
  const decimalFullNumber = Number.parseFloat(decimalFullString);
  const decimalChar = decimalFormat.format(decimalFullNumber).charAt(1); // e.g. '.' or ','
  const fixed = new Decimal(amount).toFixed(decimals);
  const [mainString, decimalString] = fixed.split('.'); // ['321321321321321321', '357' | '998']
  const mainFormat = new Intl.NumberFormat(locale, { minimumFractionDigits: 0 });
  let mainBigInt = BigInt(mainString); // 321321321321321321n
  const mainFinal = mainFormat.format(mainBigInt); // '321.321.321.321.321.321' | '321.321.321.321.321.322'
  const decimalFinal = typeof decimalString !== 'undefined' ? `${decimalChar}${decimalString}` : ''; // '.357' | '.998'
  const amountFinal = `${mainFinal}${decimalFinal}`; // '321.321.321.321.321.321,36' | '321.321.321.321.321.322,00'
  // console.log({
  //   amount,
  //   fixed,
  //   mainString,
  //   decimalString,
  //   'decimalString.length': decimalString ? decimalString.length : undefined,
  //   decimalFormat,
  //   decimalFinal,
  //   mainFormat,
  //   mainBigInt,
  //   mainFinal,
  //   amountFinal,
  // });
  return amountFinal;
}

/**
 *
 * @param amount {string}
 * @param decimals {number} e.g. 6 would return 6 decimal places like 0.000000
 * @param divisorPower {number} e.g. 0 for yocto, 24 for [base], 27 for kilo, etc 
 * @param locale {string} e.g. 'en-US' or 'de-DE'
 * @returns {string} e.g. 1,000.000000
 */
export function round(amount: string, decimals = 0, divisorPower = 0, locale?: string): string {
  if (divisorPower < 0) {
    throw new Error('divisorPower must be >= 0');
  }
  const amountCleaned = amount.replaceAll('_', '');
  const divisor = Math.pow(10, divisorPower);
  const value: string = exactMath.div(amountCleaned, divisor, { returnString: true, maxDecimal: amount.length + decimals }); // ⭐ https://www.npmjs.com/package/exact-math#the-config-maxdecimal-property-usage
  // console.log(`round(${amount}, decimals = ${decimals}, divisorPower = ${divisorPower}) = ${value}`, divisor);
  const localeString = getLocaleStringToDecimals(value, decimals, locale);
  return localeString;
}


My test cases pass now. Thanks!

P.S. See https://stackoverflow.com/a/68906367/ for what inspired my code.

Upvotes: 1

Related Questions