Format currency with Intl: practical tips and trade-offs
Building currency strings by hand—concatenating symbols, managing decimal places, guessing locale rules—is a trap we've all fallen into. The browser's Intl.NumberFormat API handles this elegantly, adapting to any locale and currency pair in a single call. We'll walk through how to use it effectively, where it shines, and where you need to think carefully about storage and display logic.
Why hand-rolled currency formatting fails
Before we dive into Intl.NumberFormat, let's acknowledge the problem. A naive approach might look like this:
const formatCurrency = (amount: number, currency: string) => {
return `$${amount.toFixed(2)}`;
};
console.log(formatCurrency(125000, 'USD'));
// Output: $125000.00 (missing thousands separator)This breaks immediately when you need a thousands separator, or when your currency isn't USD. Even worse, if your user is in Sri Lanka and prefers Sri Lankan Rupees, the format changes entirely. You'd end up hardcoding rules for each currency and locale, duplicating logic across your codebase. The real solution is built into the browser.
Intl.NumberFormat handles symbols and separators
The Intl.NumberFormat API knows the correct symbol, decimal places, and grouping rules for every currency. Here's the same example done right:
const usdFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
console.log(usdFormatter.format(125000));
// Output: $125,000.00Notice what happened: the symbol came first, the thousands separator appeared automatically, and two decimal places were preserved. You didn't have to ask for any of this. The formatter knows USD needs a dollar sign and two decimal places because USD is defined in the CLDR standard.
Let's look at a currency with different rules:
const jpyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'JPY',
});
console.log(jpyFormatter.format(125000));
// Output: ¥125,000Japanese Yen has no decimal places by default. The formatter knew that. You didn't have to check the currency code or write special logic—it's baked in.
Now consider the Indian Rupee, which uses a different grouping pattern (lakhs and crores instead of thousands and millions):
const inrFormatter = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
});
console.log(inrFormatter.format(1234567));
// Output: ₹12,34,567.00The grouping automatically adapted to the Indian numbering system. This is what "locale-aware" really means.
Locale-aware formatting
The first argument to Intl.NumberFormat is the locale tag. This controls not just the symbol but also the separator style and ordering. Compare the same amount in different locales:
const amount = 1234.56;
// Germany uses comma as decimal separator
const deFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
console.log(deFormatter.format(amount));
// Output: 1.234,56 €
// France also uses comma but with a space
const frFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
});
console.log(frFormatter.format(amount));
// Output: 1 234,56 €
// US uses period as decimal separator
const usFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
});
console.log(usFormatter.format(amount));
// Output: €1,234.56Same amount, same currency, three entirely different strings. Your backend doesn't care about locale—it stores 1234.56. But your UI must adapt. This is why you format at display time only, a principle we'll return to.
You can also pass an array of locales and the browser will use the first one it supports:
const formatter = new Intl.NumberFormat(['de', 'en-US'], {
style: 'currency',
currency: 'EUR',
});
// Prefers German if available, falls back to US EnglishWorks for LKR, USD and every currency
The code in your requirement perfectly demonstrates this universality:
new Intl.NumberFormat('en-LK', {
style: 'currency', currency: 'LKR',
}).format(125000);
// Output: Rs 125,000.00Sri Lankan Rupees, formatted according to Sri Lankan conventions. The formatter doesn't need a lookup table you've maintained. If the CLDR standard defines it (and it does for virtually every currency), Intl.NumberFormat handles it.
Here's a practical scenario: you're building a SaaS platform for merchants across India, Sri Lanka, and the US. Each merchant selects their preferred currency and locale in settings. When you display an order total, you format on the client:
interface OrderDisplay {
amount: number;
userCurrency: string;
userLocale: string;
}
function formatOrderTotal(order: OrderDisplay): string {
const formatter = new Intl.NumberFormat(order.userLocale, {
style: 'currency',
currency: order.userCurrency,
});
return formatter.format(order.amount);
}
// Different merchants, different outputs
console.log(formatOrderTotal({
amount: 5000,
userCurrency: 'USD',
userLocale: 'en-US',
}));
// Output: $5,000.00
console.log(formatOrderTotal({
amount: 5000,
userCurrency: 'LKR',
userLocale: 'en-LK',
}));
// Output: Rs 5,000.00
console.log(formatOrderTotal({
amount: 5000,
userCurrency: 'INR',
userLocale: 'en-IN',
}));
// Output: ₹5,000.00Your server stores the raw number. Formatting happens where it should: at display time, respecting the user's environment.
Format at display time only
This is the critical rule that prevents bugs down the line. Never store formatted currency strings in your database.
Here's what happens when you break this rule:
// ❌ WRONG: storing formatted output
const saveOrder = async (amount: number) => {
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
await db.orders.create({
displayAmount: formatted, // Storing "$5,000.00"
rawAmount: amount,
});
};
// Later, you need to display the same amount in EUR for a European user
// Too bad—you have "$5,000.00" stored and can't extract the number reliablyThe formatted string is no longer data; it's presentation. Once you store it, you've lost flexibility. You can't reformat it for a different locale, you can't use it in calculations, and you can't trust it if the user's locale changes.
The correct pattern:
// ✅ CORRECT: store raw numbers
interface Order {
id: string;
amountInCents: number; // Always store raw, unformatted values
currency: string;
}
const saveOrder = async (amount: number, currency: string) => {
const amountInCents = Math.round(amount * 100);
await db.orders.create({
amountInCents,
currency,
});
};
// Display time: format based on user's locale
const displayOrder = (order: Order, userLocale: string) => {
const formatter = new Intl.NumberFormat(userLocale, {
style: 'currency',
currency: order.currency,
});
const amountInDollars = order.amountInCents / 100;
return formatter.format(amountInDollars);
};
// Same order, different locales
const order = { id: '123', amountInCents: 500000, currency: 'USD' };
console.log(displayOrder(order, 'en-US'));
// Output: $5,000.00
console.log(displayOrder(order, 'de-DE'));
// Output: 5.000,00 $By storing the raw amount and currency separately, you preserve the ability to reformat, recalculate, and transmit the value across systems without data loss.
Common mistakes
Mistake 1: Creating a new formatter for every format call. If you're formatting hundreds of prices on a page, consider caching the formatter:
// ❌ Inefficient
prices.map(p => new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(p));
// ✅ Better
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
prices.map(p => formatter.format(p));Formatters are reusable objects. Create them once, reuse them often.
Mistake 2: Assuming decimal places. Not all currencies have two decimal places. Some have none, some have three. Don't hardcode .toFixed(2):
// ❌ Wrong for many currencies
const amount = 100;
console.log(`$${amount.toFixed(2)}`);
// Works for USD, fails for JPY (no decimals), fails for TND (three decimals)
// ✅ Let Intl handle it
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'JPY',
});
console.log(formatter.format(amount));
// Output: ¥100 (correct—no decimals)Mistake 3: Mixing display logic and business logic. Currency formatting is strictly display logic. Never use formatted strings for comparisons, calculations, or API payloads:
// ❌ Wrong
const formatted = formatter.format(1000.50);
if (formatted > "$1000") { // String comparison, nonsensical
// ...
}
// ✅ Correct
if (1000.50 > 1000) {
// ...
}Key takeaways
Intl.NumberFormatknows the correct symbol, grouping, and decimal places for every currency and locale; you don't need to maintain this yourself.- Always pass both a locale and a currency code. The locale determines separator style; the currency code determines the symbol and precision.
- Store raw numbers (amount and currency) in your database, never formatted strings. This preserves flexibility and prevents data corruption.
- Format only at display time, in the UI layer. This is where locale information is available and where formatting makes sense.
- Reuse formatter instances when formatting multiple values—creating a new formatter for each call is wasteful.
- The same amount displays differently across locales and currencies. Trust
Intl.NumberFormatto get it right instead of writing locale-specific branches.






Comments
Loading comments…