Working with Currency Values in TypeScript

joshuaslate

Joshua Slate

Posted on December 1, 2022

Working with Currency Values in TypeScript

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);
Enter fullscreen mode Exit fullscreen mode

There are additional options available that you can view here.

Try it!

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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.

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Bonus: Putting it All Together in a React Component

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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. If you learned something, don't hesitate to share.

💖 💪 🙅 🚩
joshuaslate
Joshua Slate

Posted on December 1, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related