~/blog

narrowSymbol makes dollars and pesos identical in es-MX

published

#javascript#i18n#intl

TL;DR

Intl.NumberFormat picks currency symbols per locale, and the default currencyDisplay: 'symbol' is allowed to use a disambiguating form. In es-MX, MXN prints as $1,439.00 and USD prints as USD 1,439.00. Ask for currencyDisplay: 'narrowSymbol' to “clean that up” and USD becomes $1,439.00 — byte-identical to pesos. So do CAD and ARS. Keep the default, or print the ISO code yourself.

The problem

You have a Mexican storefront. Prices are pesos, formatted with the obvious call:

new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(1439)
// → '$1,439.00'

Then one product is billed in dollars — a subscription, an imported SKU, an API that quotes USD. Same call, different currency:

new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'USD' }).format(1439)
// → 'USD 1,439.00'

That USD prefix looks like a formatting bug to anyone reviewing the page, so somebody reaches for the option whose name promises a tidier symbol:

new Intl.NumberFormat('es-MX', {
  style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol',
}).format(1439)
// → '$1,439.00'

The page now shows $1,439.00 for a price of about 26,000 pesos, next to another $1,439.00 that really is 1,439 pesos. Nothing throws. Nothing lints. The two strings are equal:

const mxn = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', currencyDisplay: 'narrowSymbol' }).format(1439);
const usd = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'USD', currencyDisplay: 'narrowSymbol' }).format(1439);
console.log(mxn === usd); // true

Here is the full matrix, from Node v26.2.0 with full ICU:

currencyDisplayes-MX + MXNes-MX + USD
symbol (default)$1,439.00USD 1,439.00
narrowSymbol$1,439.00$1,439.00
codeMXN 1,439.00USD 1,439.00
name1,439.00 pesos mexicanos1,439.00 dólares estadounidenses

And it is not only USD. In es-MX, narrowSymbol collapses four different currencies onto one glyph:

const symbolFor = (currency, currencyDisplay) =>
  new Intl.NumberFormat('es-MX', { style: 'currency', currency, currencyDisplay })
    .formatToParts(0)
    .find((p) => p.type === 'currency').value;

for (const c of ['MXN', 'USD', 'CAD', 'ARS']) {
  console.log(c, JSON.stringify(symbolFor(c, 'symbol')), JSON.stringify(symbolFor(c, 'narrowSymbol')));
}
// MXN "$"   "$"
// USD "USD" "$"
// CAD "CAD" "$"
// ARS "ARS" "$"

Why it happens

The symbols do not come from the JS engine. They come from CLDR, the Unicode locale data that ICU ships, and CLDR stores two forms per currency per locale.

currencyDisplay: 'symbol' maps to the first, 'narrowSymbol' to the second. So the behaviour is not a bug and will not be “fixed”: you asked for the ambiguous form and got it.

The mirror image shows the same rule from the other side. Format pesos in a US locale and CLDR does the disambiguating in the opposite direction:

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'MXN' }).format(1439)
// → 'MX$1,439.00'
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'MXN' }).format(1439)
// → '1439,00 MXN'

Each locale keeps $ for the dollar it considers local, and pushes the others to a prefixed or coded form.

What to do

Keep the default. If a page can ever show more than one currency, currencyDisplay: 'symbol' is the safe setting, and USD 1,439.00 next to $1,439.00 is the feature, not the blemish.

If you want a narrow symbol anyway, add the code yourself rather than trusting the glyph:

function formatMoney(value, currency, locale = 'es-MX') {
  const nf = new Intl.NumberFormat(locale, {
    style: 'currency', currency, currencyDisplay: 'narrowSymbol',
  });
  const local = new Intl.NumberFormat(locale).resolvedOptions().locale;
  const isHome = currency === homeCurrencyOf(local);
  return isHome ? nf.format(value) : `${nf.format(value)} ${currency}`;
}

function homeCurrencyOf(locale) {
  // Keep this table explicit. There is no standard API that maps a locale to
  // its currency, and guessing from the region subtag is wrong for the euro
  // zone and for dollarised economies.
  return { 'es-MX': 'MXN', 'en-US': 'USD', 'es-AR': 'ARS' }[locale] ?? null;
}

Assert the ambiguity away in a test. This is three lines and it catches the day someone adds a second currency to a single-currency app:

import { strict as assert } from 'node:assert';

const symbolFor = (locale, currency, currencyDisplay) =>
  new Intl.NumberFormat(locale, { style: 'currency', currency, currencyDisplay })
    .formatToParts(0)
    .find((p) => p.type === 'currency').value;

const locale = 'es-MX';
const currencies = ['MXN', 'USD'];
const symbols = currencies.map((c) => symbolFor(locale, c, 'symbol'));
assert.equal(new Set(symbols).size, currencies.length, `ambiguous symbols in ${locale}: ${symbols}`);

Swap 'symbol' for 'narrowSymbol' in that test and it fails, which is exactly the signal you want.

When the currency is genuinely fixed — a checkout that only ever charges pesos, an invoice with the currency printed in its header — narrowSymbol is fine. That is the context CLDR designed it for.

Caveats

References