dinerojs / dinero.js

Create, calculate, and format money in JavaScript and TypeScript.
https://v2.dinerojs.com/docs
MIT License
6.31k stars 188 forks source link

Feature Request: Enhance toFormat to support generic return types #645

Closed danrivett closed 1 year ago

danrivett commented 2 years ago

Use Case

We ran into a scenario where we want to format a Dinero object in a user-specified locale but not to a single string, but to its component parts - e.g. localized currency symbol, localized numeric amount in the localized order (e.g. currency symbol may prefix or suffix the numeric amount).

The reason for doing that is so we can use a library such as react-native-currency-input where we can specify separate prefix and suffix components to render the localized currency symbol separate from the monetary numeric value.

Currently the toFormat() function only supports a Transformer function which returns a string, the Transformer can't return a generic return type, even though there does not appear to be any reason not to.

Existed Related Support

A common way of localizing monetary values is to use Intl.NumberFormat#format which returns a single string and so can already be used with toFormat:

const d = dinero({ amount: 500, currency: USD });

const formattedResult = toFormat(d, ({ amount, currency }) => {
  const formatter = new Intl.NumberFormat('fr-CA', {
    style: 'currency',
    currency: currency.code,
  });

  return formatter.format(amount);
});

// formattedResult = "5,00 $ US"

However Intl.NumberFormat also provides a formatToParts() function which localizes and formats a monetary value and returns an array of component parts that can be rendered individually as desired.

Desired Solution

What is desired is for toFormat() to support a generic return type so that the following is also supported:

const d = dinero({ amount: 500, currency: USD });

const formattedResult = toFormat(d, ({ amount, currency }) => {
  const formatter = new Intl.NumberFormat('fr-CA', {
    style: 'currency',
    currency: currency.code,
  });

  return formatter.formatToParts(amount);
});

// formattedResult = [{"type":"integer","value":"5"}, {"type":"decimal","value":","}, {"type":"fraction","value":"00"}, {"type":"literal","value":" "}, {"type":"currency","value":"$ US"}] 

For backwards compatibility that generic return type can default to a string, but the passed in formatter could return a different result object type if desired, like the above does.

Contribution

The changes required to support this use case are minimal and are fully backwards compatible (as far as I can tell) and so I've implemented it myself in #646 and submitted for review.

It's worth pointing out that this PR purely contribues type changes, and doesn't introduce any runtime changes.

I would love to see this merged in as this was motivated by a near-term upcoming user story to implement a localized TextInput field where the user just edits the monetary amount and the currency symbol is localized appropriately and rendered separately. There are also later stories that we believe will also make use of this.

danrivett commented 2 years ago

The PR (#646) is built on top of the main branch, but in the meantime I've also built patch files on top of the latest released version as of this comment (2.0.0-alpha.8) in order for us to proceed with dependent stories. I've attached them below in case they are useful for anyone else.

They can be applied by:

  1. Adding them to a patches sub-directory
  2. Renaming them to remove the .txt suffices
  3. Applying them automatically on build through patch-package

Patches

Safety

As they contain no runtime changes the patch is safe to consume by intermediate libraries in order to provide additional formatting options. We've done just that in our intermediate Money library.

Example Usage

Here's a simplified example of what this PR/patches enable:

// Internal Shared Formatter

const sharedCreateFormatter = <T>(
  locale: Intl.Locale,
  numberFormatter: NumberFormatter<T>,
  formattingOptionsFn?: FormattingOptionsProvider
): MoneyFormatter<T> => {
  // This Transformer function returns a generic return type which requires the following PR to be merged into dinerojs:
  //  https://github.com/dinerojs/dinero.js/pull/646
  // And in the meantime is being patched via patch-package
  const transformer = (transformerOptions: TransformerOptions): T => {
    const options = formattingOptionsFn ? formattingOptionsFn(transformerOptions) : undefined;

    const format = new Intl.NumberFormat(locale.baseName, options);

    return numberFormatter(transformerOptions, format);
  };

  return (money: Money) => toFormat(money, transformer);
};

// --------------------------
// External String Formatter
// --------------------------

const stringFormatter: NumberFormatter<string> = ({ amount }: TransformerOptions, numberFormat: Intl.NumberFormat) => numberFormat.format(amount);

export const createFormatter = (locale: Intl.Locale, style: FormatStyle | keyof typeof FormatStyle = FormatStyle.STANDARD): MoneyFormatter<string> =>
  sharedCreateFormatter(locale, stringFormatter, createFormattingOptions(style));

// Locale is passed as an Intl.Locale Object for additional typesafety to avoid style parameter accidentally being set as locale if missed
export const formatMoney = (money: Money, locale: Intl.Locale, style?: FormatStyle | keyof typeof FormatStyle): string =>
  createFormatter(locale, style)(money);

// -------------------------
// External Parts Formatter
// -------------------------

const partsFormatter: NumberFormatter<FormattedMoneyParts> = ({ amount, currency }: TransformerOptions, numberFormat: Intl.NumberFormat) => ({
  amount,
  currency: currency.code,
  formattedParts: numberFormat.formatToParts(amount),
});

export const createPartsFormatter = (
  locale: Intl.Locale,
  style: FormatStyle | keyof typeof FormatStyle = FormatStyle.STANDARD
): MoneyFormatter<FormattedMoneyParts> => sharedCreateFormatter(locale, partsFormatter, createFormattingOptions(style));

// Locale is passed as an Intl.Locale Object for additional typesafety to avoid style parameter accidentally being set as locale if missed
export const formatMoneyIntoParts = (money: Money, locale: Intl.Locale, style?: FormatStyle | keyof typeof FormatStyle): FormattedMoneyParts =>
  createPartsFormatter(locale, style)(money);
sarahdayan commented 1 year ago

toFormat was removed in v2.0.0-alpha.11 in favor of toDecimal which supports a generic output.