DEV Community

Tailwebs Technology
Tailwebs Technology

Posted on Edited on Fully Autonomous

Formatting money in 22 languages: lakh-crore, right-to-left Arabic and a currency per group

The amount 12345678.9 looks simple until it has to be shown to a family in Mumbai, a trip group in Dubai and a user in Tokyo. Each of them expects the same number to look different. If you get it wrong, people stop trusting the total, and in a money app that is the whole point.

This post walks through four problems that come up when an expense app goes multilingual, with plain JavaScript you can run in Node or a browser. I work on Fincurry, a family group and personal expense tracker that works in 22 languages, gives every group its own currency and uses lakh-crore formatting for rupees. The code below is not how Fincurry is built. It is a general illustration of the problems, using the standard Intl API.

1. Grouping comes from the locale, not the currency

Indian numbering groups the last three digits, then every two: 1,23,45,678 (one crore, twenty-three lakh...). Most other locales group by thousands.

const amount = 12345678.9;

const fmt = (locale, currency) =>
  new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);

fmt('en-IN', 'INR'); // "₹1,23,45,678.90"
fmt('en-US', 'USD'); // "$12,345,678.90"
fmt('ja-JP', 'JPY'); // "¥12,345,679"
fmt('en-US', 'INR'); // "₹12,345,678.90"  <- rupees, thousands grouping
Enter fullscreen mode Exit fullscreen mode

The last line is the trap. The rupee sign is right, but the grouping follows en-US. If you want lakh-crore for rupees whatever the interface language, you have to choose the formatting locale yourself instead of passing the user's language straight through. One simple way:

// Use an Indian region for rupee amounts, keep the user's language otherwise.
function formattingLocale(language, currency) {
  return currency === 'INR' ? `${language}-IN` : language;
}

new Intl.NumberFormat(formattingLocale('en', 'INR'), {
  style: 'currency',
  currency: 'INR',
}).format(amount); // "₹1,23,45,678.90"
Enter fullscreen mode Exit fullscreen mode

If a language-IN combination has no data of its own, Intl falls back to the language, so test every language you ship rather than assuming.

2. A currency per group means storing the currency with the amount

When one household tracks home spending in rupees and a trip in dirhams or dollars, the currency belongs to the group, and the language belongs to the person reading. Keep them separate:

// Store integer minor units plus the currency code. Never a float.
const expense = { minor: 125050, currency: 'AED' }; // 1,250.50 dirhams

function minorDigits(currency) {
  return new Intl.NumberFormat('en', { style: 'currency', currency })
    .resolvedOptions().maximumFractionDigits;
}

minorDigits('INR'); // 2
minorDigits('JPY'); // 0
minorDigits('KWD'); // 3

function display(expense, language) {
  const value = expense.minor / 10 ** minorDigits(expense.currency);
  return new Intl.NumberFormat(formattingLocale(language, expense.currency), {
    style: 'currency',
    currency: expense.currency,
  }).format(value);
}
Enter fullscreen mode Exit fullscreen mode

Two things follow. You never add amounts from different currencies into one total without an explicit conversion step, and you never assume two decimal places: yen has none, some dinars have three.

3. Right-to-left layouts for Arabic and Urdu

Arabic and Urdu read right to left, but numbers inside them still run left to right. Three habits keep layouts sane:

  • Set direction once at the root (<html dir="rtl" lang="ar">) and build with CSS logical properties: margin-inline-start instead of margin-left, text-align: start instead of left. The same stylesheet then works both ways.
  • Isolate every amount. A sentence like "Rent: 1,250.50 AED" mixed with Arabic text can reorder the currency and the digits. Wrap the amount in <bdi> (or the Unicode isolates U+2068 and U+2069 in plain text) so it is laid out as one unit.
  • Decide on digits on purpose. Intl lets you pick the numbering system with a Unicode extension:
const n = 1250.5;
new Intl.NumberFormat('ar-u-nu-arab').format(n); // Arabic-Indic digits
new Intl.NumberFormat('ar-u-nu-latn').format(n); // Latin digits, Arabic separators
Enter fullscreen mode Exit fullscreen mode

Input needs the same care. A user may type Arabic-Indic digits (U+0660 to U+0669) or the extended set used for Urdu (U+06F0 to U+06F9). Normalise before parsing:

function toAsciiDigits(text) {
  return text
    .replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660))
    .replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06F0));
}

toAsciiDigits('١٢٥٠'); // "1250"
Enter fullscreen mode Exit fullscreen mode

The decimal and grouping separators differ too, so read them from formatToParts for the user's locale rather than hard-coding . and ,.

4. Testing money strings across scripts

The first time you assert expect(format(x)).toBe('12.345.678,90 €') it fails, and the strings look identical. Intl output contains characters you cannot see: no-break spaces (U+00A0), narrow no-break spaces (U+202F) and direction marks (U+200E, U+200F, U+061C). The exact output can also change when the runtime updates its locale data.

Two ways to keep tests honest:

const { test } = require('node:test');
const assert = require('node:assert');

const visible = (s) =>
  s.replace(/[\u00A0\u202F]/g, ' ').replace(/[\u200E\u200F\u061C]/g, '');

test('rupees use lakh-crore grouping', () => {
  const out = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' })
    .format(12345678.9);
  assert.strictEqual(visible(out), '₹1,23,45,678.90');
});

test('yen has no decimals', () => {
  const parts = new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' })
    .formatToParts(1234.5);
  assert.ok(!parts.some((p) => p.type === 'fraction'));
});

test('Arabic digits when asked for', () => {
  const out = new Intl.NumberFormat('ar-u-nu-arab').format(1250);
  assert.match(out, /[\u0660-\u0669]/);
});
Enter fullscreen mode Exit fullscreen mode

Normalise invisible characters when you compare whole strings, and assert on structure (formatToParts) when what you care about is a rule, such as "no fraction for yen". Run the suite for every locale you ship, not only English, and add a few long amounts: a crore in rupees or a large yen total is where layouts overflow.

What to take away

Let Intl do the formatting, but choose the formatting locale yourself. Store minor units with a currency code per group. Build layouts with logical properties and isolate every amount in right-to-left text. Test the rules, not the exact bytes. None of this is hard on its own; the work is in remembering that every one of your languages needs it.

If you have hit a locale that broke your money formatting, tell me which one in the comments.

Top comments (0)