Implementing localized amounts
The amounts recommendation has two exact boundaries:
localized input + locale → exact decimal → integer base units
integer base units → exact decimal + locale → localized displayThe examples below represent an exact decimal as an unsigned integer coefficient (bigint) and a scale (often called "decimals", int). 1234.56 is { coefficient: 123456n, scale: 2 }. Languages with a native arbitrary-precision decimal type can use that instead.
Parsing
A localized parser needs four pieces of locale data:
- the digit characters (0-9 or their non-Latin equivalents);
- the decimal separator;
- the grouping separator (when applicable);
- the primary and secondary grouping widths.
Obtain these from the platform's locale or CLDR facilities.
export type ExactDecimal = { coefficient: bigint; scale: number; };
type LocaleRules = {
digits: ReadonlyMap<string, string>;
decimal: string;
group?: string;
primaryGroupWidth: number;
secondaryGroupWidth: number;
};
const getLocaleRules = (locale: string): LocaleRules => {
const formatter = new Intl.NumberFormat(locale);
const parts = formatter.formatToParts(123_456_789.1);
const digits = new Map<string, string>();
// Even though some locales use non-Latin digits, ASCII digits are always accepted as input.
for (let digit = 0; digit <= 9; digit += 1) {
const ascii = String(digit);
const localized = new Intl.NumberFormat(locale, { useGrouping: false }).format(digit);
digits.set(ascii, ascii);
digits.set(localized, ascii);
}
// The rightmost group is the primary width. Earlier groups can use a
// different width, as in `1,23,45,678` for `hi-IN`.
const widths = formatter.formatToParts(123_456_789_012_345_678_901n)
.filter(part => part.type === "integer")
.map(part => Array.from(part.value).length);
return {
digits,
decimal: parts.find(part => part.type === "decimal")?.value ?? ".",
group: parts.find(part => part.type === "group")?.value,
primaryGroupWidth: widths.at(-1) ?? 3,
secondaryGroupWidth: widths.at(-2) ?? widths.at(-1) ?? 3,
};
};
const hasValidGrouping = (value: string, primary: number, secondary: number) => {
// A value with no grouping is always valid.
if (!value.includes("_")) return /^\d*$/u.test(value);
const groups = value.split("_");
const first = groups[0] ?? "";
const middle = groups.slice(1, -1);
const last = groups.at(-1) ?? "";
return /^\d+$/u.test(first)
&& first.length <= secondary
&& middle.every(group => /^\d+$/u.test(group) && group.length === secondary)
&& /^\d+$/u.test(last)
&& last.length === primary;
};
export const parseLocalizedDecimal = (input: string, locale: string): ExactDecimal | undefined => {
const rules = getLocaleRules(locale);
let normalized = "";
for (const character of input) {
const digit = rules.digits.get(character);
if (digit !== undefined) normalized += digit;
else if (character === rules.decimal) normalized += ".";
// Normalize grouping to `_` for validation, then discard it.
else if (rules.group !== undefined && character === rules.group) normalized += "_";
else return;
}
const parts = normalized.split(".");
if (parts.length > 2) return;
const integer = parts[0] ?? "";
const fraction = parts[1] ?? "";
if (!hasValidGrouping(integer, rules.primaryGroupWidth, rules.secondaryGroupWidth)) return;
if (!/^\d*$/u.test(fraction)) return;
if (`${integer}${fraction}` === "" || normalized.endsWith(".")) return;
return {
coefficient: BigInt(`${integer.replaceAll("_", "")}${fraction}`),
scale: fraction.length,
};
};
export const toBaseUnits = (amount: ExactDecimal, decimals: number) => {
if (!Number.isInteger(decimals) || decimals < amount.scale || decimals > 255) return;
return amount.coefficient * (10n ** BigInt(decimals - amount.scale));
};This parser accepts complete values only. An input component can separately retain a potential prefix such as 1. or 1,00 while the user is still typing; only a complete parsed value should reach confirmation or signing.
Display
Pass an exact numeric string to Intl.NumberFormat. This uses the ES2023 Intl API declarations and never converts the amount to floating point.
export const formatBaseUnits = (baseUnits: bigint, locale: string, decimals: number) => {
if (baseUnits < 0n || !Number.isInteger(decimals) || decimals < 0 || decimals > 100) return;
const exactValue = `${baseUnits}E-${decimals}`;
return new Intl.NumberFormat(locale, { maximumFractionDigits: decimals })
.format(exactValue);
};