# Converting a number to a string with a maximum of 3 numerals plus SI unit

I wrote a function that receives a number as an argument and returns a number (technically, a string) with a maximum of 3 numerals, no matter if there's a decimal separator or not, plus the adequate SI unit. Here some examples for you to understand its logic:

0.1 --> 0.1

42.300099 --> 42.3

1026 --> 1.03k

1200000 --> 1.2M

543760 --> 544k

34069000000 --> 34.1G

As you can see, it returns a maximum of 3 numerals (for instance, these are acceptable results: 1.23, 12.3 and 123, plus any unit).

The function works (as requested in any CR question), but it feels unnecessarily convoluted. Here's the code working in the snippet, with the examples above:

function formatNumber(value) {
const units = ["", "k", "M", "B"];
const digits = Math.max(Math.floor(Math.log10(value)), 0) + 1;
const keep = digits % 3 === 1 ? 2 : digits % 3 === 2 ? 1 : 0;
const places = ~~((digits - 1) / 3);
let unit = units[places];
let number = Math.round((10 ** keep) * value / (10 ** (places * 3))) / (10 ** keep);
if (number === 1e3) {
number = Math.round(number / 1e3);
unit = units[places + 1];
};
return number + unit;
};

[0.1, 42.300099, 1026, 1200000, 543760, 34069000000].forEach(e => console.log(e + " --> " + formatNumber(e)))

This is for financial data, so I have B for billions instead of the proper SI Giga. Also, since it deals with money, the code right now has no protection for values over 1 trillion (it will return number + undefined which is NaN), negative numbers and other cases, but I'm aware of that.

Here is the detailed explanation of what I tried to do:

function formatNumber(value) {

//the SI units, changing G for B
const units = ["", "k", "M", "B"];

//this gets how many numerals the number has, I found it in this
const digits = Math.max(Math.floor(Math.log10(value)), 0) + 1;

//based on 'digits', this value will be used to determine how many numerals there will
//be after the decimal point. The remainder is used to calculate it: if we have
//just 1 numeral inside that SI unit, there's space for 2 more numerals; if we have
//2, there's space for just 1 more, and if we have 3 there's no space
const keep = digits % 3 === 1 ? 2 : digits % 3 === 2 ? 1 : 0;

//based on 'digits', we get the corresponding SI unit
const places = ~~((digits - 1) / 3);
let unit = units[places];

//using 'keep', this sets by how many powers of ten we'll multiply the number, round it
//and divide again by such power of ten. That's the same logic of keeping just two
//decimal places in a long decimal number, when we multiply it by 100, round the result
//and then divide it by ten again
let number = Math.round((10 ** keep) * value / (10 ** (places * 3))) / (10 ** keep);

//because of the rounding up, some cases like 999999 will return 1000k instead of 1M,
//so I wrote this 'if' condition
if (number === 1e3) {
number = Math.round(number / 1e3);
unit = units[places + 1];
};

return number + unit;
};


I appreciate any feedback.

I found your logic somewhat difficult to follow, especially because the number of significant digits, 3, is hard-coded into many calculations. I came up with a similar but more explicit approach (formatting numbers should not be a performance-sensitive part of your application, I would hope!).

    const defaultUnits = new Map([[1e0, ""], [1e3, "K"], [1e6, "M"], [1e9, "G"]]);

function getUnitValue(value, units) {
const unitValues = Array.from(units.keys()).sort((a, b) => b - a);
const absValue = Math.abs(value);
for (const unitValue of unitValues) {
if (absValue >= unitValue) {
return unitValue;
}
}
return 1;
}

function checkBase(base) {
if (base < 0) {
throw RangeError(Negative bases, such as ${base}, are not supported); } else if (base - Math.floor(base) > 0) { throw RangeError(Bases with fractional components, such as${base}, are not supported)
}
}

function integerDigitCount(value, base) {
checkBase(base);
return Math.max(Math.ceil(Math.log(Math.abs(value)) / Math.log(base)), 1); // leading 0 is one non-decimal place
}

function formatNumber(value, numDigits, base = 10, units = defaultUnits) {
checkBase(base);
const initialUnitValue = getUnitValue(value, units);
const valueToUnit = value / initialUnitValue;
const nonDecimalPlaces = integerDigitCount(valueToUnit, base);
const reqdDecimalPlaces = Math.round(numDigits - nonDecimalPlaces);
const reqdDecimalMultiplier = (base ** reqdDecimalPlaces);
const valueToUnitSigDigits = Math.round(valueToUnit * reqdDecimalMultiplier) / reqdDecimalMultiplier;
if (Math.abs(valueToUnitSigDigits) >= base ** numDigits) {
return formatNumber(valueToUnitSigDigits * initialUnitValue, numDigits, base, units); // recurse if not fully formatted
}
const valueString = valueToUnitSigDigits.toString(base);
return valueString + units.get(initialUnitValue);
}

const sampleTestCases = [0.0, 0.1, 0.434245, 235.5, 0.00001, 42.300099, 1026, 1200000, 543760, 34069000000, 999999, 99999, 999.999, 999.9999999];
function runTests(testCases = sampleTestCases, numDigits = 3) {
// generate negative testcases as well
const allTestCases = testCases.concat(testCases.map(testCase => -testCase));
const results = allTestCases.map(testCase => [testCase, formatNumber(testCase, numDigits)]);
const numDigitsResult = results.map(result => [result[0], result[1], result[1].replace(/[^\d]/g, '').length]);
const numDigitsTest = numDigitsResult.map(result => [result[0], result[1], result[2], result[2] <= numDigits]);
numDigitsTest.forEach(result => console.log(${result[0]} --->${result[1]}; |${result[1]}| =${result[2]}; ${result[2]} <=${numDigits} is \${result[3]}));
const assertion = numDigitsTest.reduce((acc, x) => (x[3] && acc), true);
console.assert(assertion);
return assertion;
}

runTests();

Overall, I feel this is a more straightforward approach, where the main idea is:

• we find the largest unit which is less than the provided value,
• express the value in terms of that unit (a quick division by the value of the unit),
• then round off to the required number of significant digits
• unfortunately involving a calculation of how many decimal places that entails
• if the number of the digits of the number we end up with exceeds the number of significant digits allowed:
• multiply the rounded-off number in units with the unit value to get back the unitless rounded-off value,
• run that value through the function again
• with all that done, take the rounded-off number in the appropriate units and concatenate the string representation of that with the appropriate unit suffix, and return that

The ES6 Map provides a nice way to group together the unit suffixes and their values, and with this algorithm you have a number of customization points - the choices of units, the base of the representation (10 for decimal, 16 for hexadecimal, etc.), and of course the number of significant digits.

Also, a bunch of Math.abs allows us to handle negative numbers in a sane way as well.

• Thanks. The number of digits (3) is hardcoded because that's what I need the function to do, maybe a name like function formatNumberWith3Numerals would make that more clear. But it was nice passing that as an argument as well. Commented Mar 2, 2022 at 8:22
• Try with values like 0.0 and values just under a power of 1000 like 999.99, 999.99999. Still work? I don't use javascript. This is from a FP standpoint. Commented Mar 23, 2022 at 18:27
• @chux-ReinstateMonica 0.0 gives 0 and the 999.99... ones give 1K. I think that's the expected behavior? Commented Mar 24, 2022 at 19:43
• I know see code's handling of edge cases with Math.max(..., 1) and if (valueToUnitSigDigits >= base ** sigDigits) .... Good! Commented Mar 25, 2022 at 11:37
• @chux-ReinstateMonica for some reason I decided to look at how well the code handled negative numbers, and turns out, it didn't really. Updated with a bunch of Math.abs calls to enable negative number support. Commented Mar 27, 2022 at 13:41