Working with Currency Values in TypeScript
Tags: webdev, typescript, javascript, finance
Published: 2022-12-01 14:01:16

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](https://www.joshuaslate.com/blog/react-i18n-guide), 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Browser support for `Intl.NumberFormat` is in great shape at this point, with [more than 97% of global Internet users](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) using browsers that support it.

Creating an `Intl.NumberFormat` formatter is quite simple:

```ts
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters).

#### Try it!
{% embed https://codesandbox.io/embed/confident-kare-lui48t?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.tsx&theme=dark %}

### 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:
```ts
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.
```ts
0.1 + 0.2 === 0.3; // false 🤯 try it in your browser console. I get: 0.30000000000000004
```
- Numbers greater than {Number.MAX_SAFE_INTEGER} and less than {Number.MIN_SAFE_INTEGER} 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](https://github.com/MikeMcl/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:

```ts
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()](https://mikemcl.github.io/big.js/#toS) and [toNumber()](https://mikemcl.github.io/big.js/#toN).

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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format#browser_compatibility) 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.

```ts
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
```tsx
import React, { BaseHTMLAttributes, useMemo } 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 = useMemo(
    () => getCurrencyFormatter(locale, {
      currency,
      currencySign,
      useGrouping,
      minimumIntegerDigits,
      minimumFractionDigits,
      maximumFractionDigits,
      minimumSignificantDigits,
      maximumSignificantDigits,
    }),
    [
      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 [Twitter](https://twitter.com/joshuaslate). If you learned something, don't hesitate to share.