Having spent the past few years working for FinTech companies, I have had to work with currency values a lot. I've learned some convenient patterns for working with formatting currency values. In this post, I'm going to break down some of my favorite patterns, and lessons I learned the hard way, so you don't need to.
Formatting and Localization#
As mentioned in my previous post about internationalization, it is important for software engineers to localize their software so that their users are receiving information in the way they best understand. As it relates to numbers, there are a variety of formats used by different locales. For example, in the US, we would format one-thousand-two-hundred-thirty-four dollars and 56 cents like so: $1,234.56. In Germany, that would be 1.234,56 $. In Mexico, it would be USD 1,234.56.
Getting Started#
The cornerstone of our currency formatting will be Intl.NumberFormat.
Browser support for Intl.NumberFormat
is in great shape at this point, with more than 97% of global Internet users
using browsers that support it.
Creating an Intl.NumberFormat
formatter is quite simple:
const formatter = new Intl.NumberFormat('default', {
style: 'currency',
currency: 'USD',
});
// Try replacing 'default' with various locales like 'en-US', 'de-DE', 'ar-EG', and 'zh-CN', for example.
formatter.format(1234.56);
There are additional options available that you can view here.
Unsupported Currencies#
While working on various financial applications, I learned that if you pass an unsupported currency (many cryptocurrencies, for example)
to the Intl.NumberFormat
constructor, it will throw an error. Using the fictional REACT
coin as an example, the following
error would be thrown: RangeError: Invalid currency code : REACT
.
One way I've worked around this is by first attempting to instantiate an Intl.NumberFormat
with the provided options, then falling back
to Bitcoin (BTC) formatting if the selected currency is unsupported.
An example:
const getNoOpFormatter = (
locale: string = 'default',
options?: Intl.NumberFormatOptions
) => ({
format: (x: number | bigint | undefined) => x?.toString() || '',
formatToParts: (x: number | bigint | undefined) => [
{ type: 'unknown' as Intl.NumberFormatPartTypes, value: x?.toString() || '' }
],
resolvedOptions: new Intl.NumberFormat(locale, options).resolvedOptions
});
export const getCurrencyFormatter = (
locale: string = 'default',
options?: Intl.NumberFormatOptions
): Intl.NumberFormat => {
try {
return new Intl.NumberFormat(locale, options);
} catch {
if (options?.style === 'currency' && options?.currency) {
const rootFormatter = new Intl.NumberFormat(locale, {
...options,
currency: 'BTC'
});
return {
format: (x: number | bigint | undefined) =>
rootFormatter
.formatToParts(x)
.map((part) =>
part.type === 'currency' ? options.currency : part.value
)
.join(''),
formatToParts: (x: number | bigint | undefined) =>
rootFormatter.formatToParts(x).map((part) =>
part.type === 'currency'
? ({
...part,
value: options.currency
} as Intl.NumberFormatPart)
: part
),
resolvedOptions: rootFormatter.resolvedOptions
};
}
return getNoOpFormatter(locale, options);
}
};
There is, however, a pitfall to this approach. It defaults to 2 maximumFractionDigits
. Depending on your currency, this
may or may not be enough. You would need to override that option to provide sufficient fractional digits.
Arithmetic and Comparison Operations#
String Values for Arithmetic#
As concepts like fractional share ownership and cryptocurrency continue to materialize and expand across the global economy, JavaScript applications will have an increasingly difficult time in dealing with numbers due primarily to two issues:
- Floating point precision: computations on floating point numbers aren't necessarily deterministic and produce incorrect results.
0.1 + 0.2 === 0.3; // false 🤯 try it in your browser console. I get: 0.30000000000000004
- Numbers greater than 9007199254740991 and less than -9007199254740991 are out of range and produce incorrect values when exceeded in either direction when performing arithmetic operations.
For financial applications, APIs commonly return currency values, balances, stock/currency positions, and other amounts as strings. The best way I've found to deal with this is to use a library like big.js.
Using a library like big.js, you are able to convert the string number values to Big
objects, which can safely perform arithmetic
and comparison operations. For example:
import Big from 'big.js';
new Big(Number.MAX_SAFE_INTEGER).times(5).toString() === '45035996273704955'; // true
new Big(Number.MIN_SAFE_INTEGER).times(5).toString() === '-45035996273704955'; // true
new Big(0.1).add(0.2).eq(0.3); // true
As you can see, the issues we were facing with JavaScript's number primitive are solved by using big.js. However, this presents another issue for us. We have two main methods for getting a usable primitive value out of our Big object: toString() and toNumber().
Here's where things get interesting again. Intl.NumberFormat.prototype.format()
has great browser support when you're passing
it a number or bigint value, but string number values are not yet well-supported and are considered experimental
at the time of writing. Anecdotally, passing string number values seems to be working for me in the latest builds of Chrome, Firefox, and even Safari.
With this in mind, using the value returned from Big.prototype.toString()
might work. Let's consider our other option.
Big.prototype.toNumber()
will return a JavaScript number primitive, but it's possible that precision will be lost. According
to the documentation, you can set Big.strict = true;
, which will cause Big.prototype.toNumber()
to throw if it's called with
a number that cannot be converted to primitive number without precision loss. Depending on the size of numbers in your application,
this might be acceptable.
It seems like we're left with two solutions that kind of get the job done in certain situations, but I think we can take it a step further.
import Big from 'big.js';
/**
* Note that in strict mode, you'll need to pass string or bigint values as the
* BigSource for various Big methods and the constructor
*/
Big.strict = true;
const safelyFormatNumberWithFallback = (formatter: Intl.NumberFormat, value: Big) => {
// First, attempt to format the Big as a number primitive
try {
return formatter.format(value.toNumber());
} catch {}
// Second, attempt to format the Big as a string primitive
try {
return formatter.format(value.toString());
} catch {}
// As a fallback, simply return the ugly string value
return value.toString();
}
Bonus: Putting it All Together in a React Component#
import React, { BaseHTMLAttributes } from 'react';
import Big from 'big.js';
import { getCurrencyFormatter, safelyFormatNumberWithFallback } from '../helpers/number'; // The functions from above
interface Props extends BaseHTMLAttributes<HTMLSpanElement>, Omit<Intl.NumberFormatOptions, 'style'> {
locale?: string;
value?: Big;
}
export const FormatCurrencyValue: React.FC<Props> = ({
currency = 'USD',
currencySign,
useGrouping,
minimumIntegerDigits,
minimumFractionDigits,
maximumFractionDigits,
minimumSignificantDigits,
maximumSignificantDigits,
locale = 'default',
value,
...rest
}) => {
const numberFormatter: Intl.NumberFormat = getCurrencyFormatter(locale, {
currency,
currencySign,
useGrouping,
minimumIntegerDigits,
minimumFractionDigits,
maximumFractionDigits,
minimumSignificantDigits,
maximumSignificantDigits,
});
return (
// I find it helpful to pass the raw value data attribute down to make debugging easier from a quick DOM inspection
<span data-value={value?.toString()} {...rest}>
{safelyFormatNumberWithFallback(numberFormatter, value)}
</span>
);
}
Parting Shot#
I would like to close this post with an opinion: currency values (or just numeric values in general) are best rendered in a monospace font. They allow users to scan through data more quickly and accurately.
As always, if I missed something or made a mistake, please reach out to me on X. If you learned something, don't hesitate to share.