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

1

2

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!)
    });
  });
});

Coenzyme answered 23/9, 2022 at 20:0 Comment(4)
Dang, even multiplying numbers by 10^28 before the operation doesn't work... I wonder why.Maracaibo
npmjs.com/package/… - add maxDecimal property to third parameter (default is 17). Probably something similar for decimal.jsEndrin
Hey, so if we just take out the decimals out of our operands, but store the position of the decimal, and then after the operation, we add back the decimal, we can get the correct result, as you can see here: tsplay.dev/mpLZxm This is using my string integer library so there are no funky fractional/decimal business involved.Maracaibo
Thank you @James! I will accept your answer if you want to write that up as one.Coenzyme
C
1

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.

Coenzyme answered 23/9, 2022 at 20:46 Comment(1)
Looks like a fine answer to me.Endrin

© 2022 - 2024 — McMap. All rights reserved.