💯Intl.NumberFormat
Locale-aware number formatting, built in. Currency symbols, thousands separators, compact “1.2M”-style notation, units, percentages — no library needed. I use this all the time.
new Intl.NumberFormat('en-GB').format(1234567.891)
// '1,234,567.891'
new Intl.NumberFormat('de-DE').format(1234567.891)
// '1.234.567,891' — separators flip for German
The options I actually use
Currency
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
}).format(1099.5)
// '£1,099.50'
// currencyDisplay: 'narrowSymbol' gives '$' instead of 'US$'
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'USD',
currencyDisplay: 'narrowSymbol',
}).format(1099.5)
// '$1,099.50'
Compact notation
Great for stats, follower counts, chart axes:
const compact = new Intl.NumberFormat('en-GB', { notation: 'compact' })
compact.format(1234) // '1.2K'
compact.format(1234567) // '1.2M'
compact.format(1234567890) // '1.2B'
Units
new Intl.NumberFormat('en-GB', {
style: 'unit',
unit: 'kilometer-per-hour',
}).format(50)
// '50 km/h'
new Intl.NumberFormat('en-GB', {
style: 'unit',
unit: 'megabyte',
unitDisplay: 'narrow',
}).format(250)
// '250MB'
Percentages
new Intl.NumberFormat('en-GB', { style: 'percent' }).format(0.85)
// '85%' — note: takes the fraction, not 85
Signs
signDisplay: 'exceptZero' is handy for deltas (“+3.2%” in green, “-1.4%” in red):
const delta = new Intl.NumberFormat('en-GB', {
style: 'percent',
signDisplay: 'exceptZero',
maximumFractionDigits: 1,
})
delta.format(0.032) // '+3.2%'
delta.format(-0.014) // '-1.4%'
Ranges
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
}).formatRange(35, 60)
// '£35–£60'
My helpers
Wrappers I’ve used across past projects — a validating base function plus thin presets on top.
Constructing an Intl.NumberFormat is comparatively expensive — .format() calls are cheap. Earlier versions of these helpers built a fresh formatter on every call, which is fine until you’re formatting a table with a few thousand cells. The fix is a small cache keyed by locale + options, so each distinct configuration only ever constructs one formatter:
const DEFAULT_DECIMAL_PLACES = 2
// Module-level cache — lives for the lifetime of the app, shared by every
// caller. Constructing an Intl.NumberFormat is the expensive part; calling
// .format() on an existing one is cheap.
const formatterCache = new Map<string, Intl.NumberFormat>()
/**
* Returns a memoised Intl.NumberFormat for the given locale + options.
* Each distinct configuration only ever constructs one formatter.
* @param locale - BCP 47 locale tag e.g. 'en-GB'. Pass undefined to use the runtime's locale.
* @param options - Standard Intl.NumberFormatOptions.
* @returns A cached (or newly created and cached) formatter instance.
*/
const getNumberFormat = (
locale?: string,
options?: Intl.NumberFormatOptions,
): Intl.NumberFormat => {
// NOTE: JSON.stringify makes a stable key only if the options object is
// always built with the same property order — true for the presets below.
const key = `${locale}:${JSON.stringify(options)}`
let formatter = formatterCache.get(key)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options)
formatterCache.set(key, formatter)
}
return formatter
}
/**
* Formats a number (or numeric string) using Intl.NumberFormat, with
* validation. Throws rather than rendering 'NaN' to the user.
* @param value - The number to format. Numeric strings ('1099.5') are parsed.
* @param options - Standard Intl.NumberFormatOptions.
* @returns The formatted number as a string.
* @throws TypeError if value is null/undefined/empty, Error if it isn't parseable as a number.
*/
export const formatNumberBase = (
value: string | number,
options?: Intl.NumberFormatOptions,
): string => {
// Reject empty values outright — Intl.NumberFormat would format
// undefined as the literal string 'NaN'.
if (value == null || value === '') {
throw new TypeError('formatNumber: Value is not defined')
}
// Guard for plain-JS callers — TS already prevents these types.
if (typeof value === 'boolean' || typeof value === 'object') {
throw new Error('formatNumber: Invalid number format')
}
// API responses and form inputs often hand over numbers as strings.
if (typeof value === 'string') {
value = Number.parseFloat(value)
if (Number.isNaN(value)) {
throw new Error('formatNumber: Invalid number format')
}
}
// Locale hardcoded — swap for undefined to follow the visitor's locale.
return getNumberFormat('en-GB', options).format(value)
}
The cache grows unbounded in theory, but in practice an app has a handful of formatting configurations, so it tops out at a dozen entries. One real caveat: don’t feed it user-assembled option objects from different sources and expect cache hits — the key depends on property order.
The string handling matters more than it looks — API responses and form inputs hand you numbers as strings all the time, and Intl.NumberFormat itself would happily format NaN as the string 'NaN' rather than throwing.
/**
* Formats a number as currency.
* @param value - The number to format.
* @param currency - The currency code e.g. USD, GBP, EUR, etc.
* @param decimalCount - Optional. The number of decimal places to display. Default is 2.
* @param options - Additional, optional options for formatting the number.
* @returns The formatted currency as a string.
*/
export const formatNumberCurrency = (
value: number | string,
currency: string,
decimalCount: number = DEFAULT_DECIMAL_PLACES,
options?: Intl.NumberFormatOptions,
) => {
return formatNumberBase(value, {
style: 'currency',
currencyDisplay: 'narrowSymbol',
currency,
maximumFractionDigits: decimalCount,
...options,
})
}
/**
* Formats a number with decimal places.
* @param value - The number to format.
* @param decimalCount - Optional. The number of decimal places to display. Default is 2.
* @param options - Additional, optional options for formatting the number.
* @returns The formatted number as a string.
*/
export const formatNumberDecimal = (
value: number | string,
decimalCount: number = DEFAULT_DECIMAL_PLACES,
options?: Intl.NumberFormatOptions,
) => {
return formatNumberBase(value, {
style: 'decimal',
maximumFractionDigits: decimalCount,
...options,
})
}
/**
* Formats a number with a fixed number of decimal places — trailing zeros kept.
* @param value - The number to format.
* @param decimalCount - The number of decimal places to display. Default is 2.
* @param options - Additional, optional options for formatting the number.
* @returns The formatted number as a string.
*/
export const formatNumberDecimalForceDecimalPlaces = (
value: number,
decimalCount: number = DEFAULT_DECIMAL_PLACES,
options?: Intl.NumberFormatOptions,
) => {
return formatNumberDecimal(value, decimalCount, {
...options,
// The `Intl.NumberFormat` constructor that `formatNumberDecimal` uses trims
// decimal places if possible so I have ensured that the number of decimal
// places will be kept no matter what here.
minimumFractionDigits: decimalCount,
})
}
formatNumberCurrency(1099.5, 'GBP') // '£1,099.50'
formatNumberCurrency('1099.5', 'USD') // '$1,099.50' — strings work too
formatNumberDecimal(3.14159) // '3.14'
formatNumberDecimal(3) // '3' — trims when it can
formatNumberDecimalForceDecimalPlaces(3) // '3.00' — forced
In React
Because the helpers cache their formatters, they’re safe to call straight from render — no useMemo gymnastics needed:
import { formatNumberCurrency } from '~/utils/formatNumber'
const PriceList = ({ prices }: { prices: number[] }) => (
<ul>
{prices.map((price) => (
<li key={price}>{formatNumberCurrency(price, 'GBP')}</li>
))}
</ul>
)
If you’re constructing a formatter directly in a component — say the locale comes from props or context — memoise it on its inputs instead:
import { useMemo } from 'react'
const Revenue = ({ amount, locale }: { amount: number; locale?: string }) => {
const formatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'GBP',
currencyDisplay: 'narrowSymbol',
}),
[locale],
)
return <strong>{formatter.format(amount)}</strong>
}
Without the useMemo, every render constructs a new formatter — invisible on one component, real jank on a re-rendering table of them.
Gotchas
percenttakes fractions —format(0.85)notformat(85).- Locale hardcoded to
en-GBin my helpers — passundefinedas the first constructor argument to use the visitor’s own locale instead. - Rounding is controllable since the v3 spec (ES2023):
roundingMode: 'trunc' | 'ceil' | 'halfEven'etc., plusroundingPriority/roundingIncrement(“round to nearest 5p”).
For plural-aware strings around these numbers (“1 item” / “2 items”) see my Intl.PluralRules note — and Intl.RelativeTimeFormat covers “2 days ago”.