narrowSymbol makes dollars and pesos identical in es-MX
published
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:
currencyDisplay | es-MX + MXN | es-MX + USD |
|---|---|---|
symbol (default) | $1,439.00 | USD 1,439.00 |
narrowSymbol | $1,439.00 | $1,439.00 |
code | MXN 1,439.00 | USD 1,439.00 |
name | 1,439.00 pesos mexicanos | 1,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.
- The standard symbol is picked so that, inside that locale, currencies stay distinguishable.
es-MXgets the plain$for its own peso, which forces every other dollar-family currency to fall back to its code — henceUSD 1,439.00. - The narrow symbol is defined as the shortest form, explicitly allowed to be ambiguous, meant for contexts where the currency is already known from elsewhere on the page. UTS #35 spells this out: the narrow form drops the disambiguating prefix and is only safe when the surrounding context supplies it.
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
- The tables above are Node v26.2.0 with full ICU. A Node built with
small-icu, or an older ICU, can ship different CLDR data;process.config.variables.icu_smalltells you which build you have, andnew Intl.NumberFormat('es-MX').resolvedOptions().localetells you whether the locale resolved at all or fell back toen-US. - Browsers carry their own ICU. Chrome, Firefox and Safari track CLDR on separate schedules, so a narrow-symbol string can differ between a server render and a client hydrate. If you compare formatted money strings across that boundary, compare numbers instead.
- The collision is per locale and per currency pair.
en-US+ MXN is unambiguous (MX$),es-MX+ USD is not undernarrowSymbol. Test the pairs your app actually uses rather than assuming a rule. - None of this touches the digits. Grouping, decimals and rounding are unaffected — only the currency part of the output changes.