Intl: numbers, dates, lists and plurals
Format numbers, currencies, dates, lists, plurals, relative times and durations for any locale, sort text correctly, and name languages and regions with the built-in Intl objects.
What you will learn
- Format numbers, currencies and percentages with Intl.NumberFormat
- Format dates and times with Intl.DateTimeFormat, always naming the time zone
- Join lists and pick plural forms with ListFormat and PluralRules
- Describe times with RelativeTimeFormat and DurationFormat
- Sort and compare text with Intl.Collator
- Name languages and regions with Intl.DisplayNames
- Explain why output must not be compared as text across machines
On this page
Hand-written formatting breaks as soon as the audience changes. 1,234.50 is 1.234,50 in Germany, dates run day-first in most of the world, and “1 file” becomes “2 files” by rules that differ per language. The Intl object contains the formatters that know those rules. It is built into every supported Node and every browser, and needs no package. The general idea of keeping the data separate from how it is shown is in the fundamentals lesson on text and strings.
Two habits make Intl code predictable, and the examples in this lesson follow both:
- Always pass a locale such as
"en-US". With none, JavaScript uses the machine’s default, so the same program prints differently on different computers. - Always pass a
timeZonewhen formatting dates. Without one, the machine’s local zone is used.
Numbers
new Intl.NumberFormat(locale, options) makes a formatter, and its format method produces text. The same number looks different in each locale.
const n = 1234567.891;for (const locale of ["en-US", "de-DE", "fr-FR", "en-IN"]) { console.log(locale, new Intl.NumberFormat(locale).format(n));}en-US 1,234,567.891de-DE 1.234.567,891fr-FR 1 234 567,891en-IN 12,34,567.891The grouping and decimal marks are different, and en-IN groups the last three digits and then pairs. The French and German outputs use a special space character (a no-break or narrow no-break space) so that numbers are never split across lines. It looks like an ordinary space, but it is not one, so do not compare formatted text with === against a typed space.
For currency, pass style: "currency" and a currency code. The code is separate from the locale: you choose the currency of the amount, and the locale decides how to write it.
function money(locale, currency, amount) { const style = "currency"; return new Intl.NumberFormat(locale, { style, currency }).format(amount);}console.log(money("en-US", "USD", 1234.5));console.log(money("de-DE", "EUR", 1234.5));console.log(money("ja-JP", "JPY", 1234.5));$1,234.501.234,50 €¥1,235The symbol goes before or after the number depending on the locale, EUR has two decimals, and JPY has none, so 1234.5 rounds to 1,235. The formatter knows the usual digits for each currency.
Other options cover the usual needs:
const pct = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1,});console.log(pct.format(0.256), pct.format(1.5));25.6% 150%const fixed = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2 });console.log(fixed.format(3), fixed.format(2.5), fixed.format(1 / 3));3.00 2.50 0.333const compact = new Intl.NumberFormat("en-US", { notation: "compact" });console.log(compact.format(1500), compact.format(2300000));1.5K 2.3Mconst speed = { style: "unit", unit: "kilometer-per-hour" };console.log(new Intl.NumberFormat("en-US", speed).format(88));88 km/hstyle: "percent"multiplies by 100 and adds the sign.maximumFractionDigitslimits the decimals.minimumFractionDigits: 2pads, so3shows as3.00. Without a maximum, the default maximum of 3 decimals applies, and1 / 3shows0.333.notation: "compact"gives1.5Kand2.3M.style: "unit"formats a quantity with a unit such askilometer-per-hour.
The result is always text. It is for showing to a person. Keep the number itself as a number for calculations, as in Numbers, and format at the last moment.
Money should still be held as whole cents or similar, and divided only for display. Intl rounds for display and does not fix floating-point sums.
Dates and times
Intl.DateTimeFormat formats a Date. The dates here are built from an ISO string with a Z at the end, which means UTC. The next lesson explains Date fully.
const moment = new Date("2026-03-14T15:30:00Z");
const base = { timeZone: "UTC", dateStyle: "long" };console.log(new Intl.DateTimeFormat("en-US", base).format(moment));console.log(new Intl.DateTimeFormat("en-GB", base).format(moment));console.log(new Intl.DateTimeFormat("de-DE", base).format(moment));console.log(new Intl.DateTimeFormat("ja-JP", base).format(moment));March 14, 202614 March 202614. März 20262026年3月14日dateStyletakesfull,long,mediumorshort, andtimeStyletakes the same.- The same moment is written
March 14, 2026,14 March 2026,14. März 2026and2026年3月14日. timeZone: "UTC"fixes the zone. Without it, a moment close to midnight can land on a different day on a different machine.
When the styles are not exact enough, choose the fields yourself.
const fields = { timeZone: "Asia/Tokyo", weekday: "long", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23",};console.log(new Intl.DateTimeFormat("en-GB", fields).format(moment));Sunday, 15 Mar 2026, 00:30The timeZone is a name from the IANA database, such as Asia/Tokyo, not an offset like +09:00. Names know about daylight saving time. hourCycle: "h23" asks for a 24-hour clock without AM and PM. The moment 15:30 UTC is already 00:30 on the next day in Tokyo, so the date moves too.
toLocaleString, toLocaleDateString and toLocaleTimeString are shortcuts that take the same locale and options.
const ny = { timeZone: "America/New_York", timeStyle: "short" };console.log(moment.toLocaleTimeString("en-GB", { ...ny, hourCycle: "h23" }));console.log(moment.toLocaleDateString("sv-SE", { timeZone: "UTC" }));11:302026-03-14The first line prints the New York time of the same moment. (March 14 is before US daylight saving begins, so New York is five hours behind UTC.) The Swedish locale, sv-SE, writes dates as 2026-03-14, which is a handy trick for a plain ISO-like date.
When you format many values, create one Intl formatter and reuse it. Each shortcut call builds a new formatter inside, which is slower.
Lists
Intl.ListFormat joins items with the right words and punctuation. The type is conjunction (“and”), disjunction (“or”) or unit.
const list = new Intl.ListFormat("en", { style: "long", type: "conjunction" });console.log(list.format(["tea", "coffee", "milk"]));console.log(list.format(["tea", "coffee"]));tea, coffee, and milktea and coffeeconst or = new Intl.ListFormat("en", { type: "disjunction" });console.log(or.format(["red", "green", "blue"]));console.log(new Intl.ListFormat("de").format(["Tee", "Kaffee", "Milch"]));red, green, or blueTee, Kaffee und MilchThe English “Oxford comma” in tea, coffee, and milk and the German und without a comma are part of the data, not of your code.
Plurals
Languages have different plural categories. English has one and other. Other languages have few, many, two or zero. Intl.PluralRules tells you the category for a number, and your code picks the matching word.
const plural = new Intl.PluralRules("en");console.log([0, 1, 2, 5].map((n) => plural.select(n)));[ 'other', 'one', 'other', 'other' ]select returns "one" only for 1 in English. Zero is other (“0 files”). With type: "ordinal" the categories are for ranking words (1st, 2nd, 3rd, 4th):
const ordinal = new Intl.PluralRules("en", { type: "ordinal" });console.log([1, 2, 3, 4, 11, 21].map((n) => ordinal.select(n)));[ 'one', 'two', 'few', 'other', 'other', 'one' ]The rules do not give you the words. You still write a table such as { one: "st", two: "nd", few: "rd", other: "th" }. For other languages you need a table per language.
Relative times
Intl.RelativeTimeFormat writes “3 months ago” and “in 2 days”. Negative numbers are the past. With numeric: "auto" it uses words like “yesterday” when the language has them.
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });console.log(rtf.format(-1, "day"), "|", rtf.format(2, "day"));console.log(rtf.format(-3, "month"));console.log(new Intl.RelativeTimeFormat("es").format(-2, "hour"));yesterday | in 2 days3 months agohace 2 horasThe method takes a number and a unit: second, minute, hour, day, week, month, quarter or year. It does not work out the unit for you or compare dates. That is up to your code, as in the last exercise.
Durations
Requires Node.js 24 or later Intl.DurationFormat writes a length of time, such as the running time of a video, from an object with hours, minutes, seconds and the other fields. It has four styles. It does not exist on Node 22.
const duration = { hours: 1, minutes: 30, seconds: 5 };
for (const style of ["long", "short", "narrow", "digital"]) { const format = new Intl.DurationFormat("en", { style }); console.log(style.padEnd(7), format.format(duration));}
const french = new Intl.DurationFormat("fr", { style: "long" });console.log(french.format({ days: 2, hours: 3 }));long 1 hour, 30 minutes, 5 secondsshort 1 hr, 30 min, 5 secnarrow 1h 30m 5sdigital 1:30:052 jours et 3 heuresThe digital style is the clock layout, and the others spell out the units in the language you ask for. The input is a plain object of numbers. It is not the same as the time between two dates: your code has to work out the hours, minutes and seconds first.
Sorting and comparing text
Sorting strings with sort() and no argument orders them by code unit, so every capital letter comes before every lowercase one and é comes after z. People expect the order of a dictionary. Intl.Collator compares strings by the rules of a language, and its compare method can be passed straight to sort:
const words = ["zebra", "Zebra", "écrit", "apple", "eagle"];const collator = new Intl.Collator("en");
console.log([...words].sort());console.log([...words].sort(collator.compare));
const files = ["file10", "file2", "File1"];const numeric = new Intl.Collator("en", { numeric: true });console.log(files.sort(numeric.compare));[ 'Zebra', 'apple', 'eagle', 'zebra', 'écrit' ][ 'apple', 'eagle', 'écrit', 'zebra', 'Zebra' ][ 'File1', 'file2', 'file10' ]The first line is the code unit order. The second is dictionary order, with écrit among the e words. The option numeric: true reads digit runs as numbers, so file2 comes before file10. Without it, file10 would sort first, because "1" comes before "2".
The sensitivity option decides what counts as different: "base" ignores case and accents, "accent" ignores only case, and "case" and "variant" care about more. The language matters too, because languages order their own letters differently:
const base = new Intl.Collator("en", { sensitivity: "base" });const accent = new Intl.Collator("en", { sensitivity: "accent" });console.log(base.compare("a", "A"), base.compare("a", "á"));console.log(accent.compare("a", "A"), accent.compare("a", "á"));
const letters = ["z", "ä", "a"];console.log([...letters].sort(new Intl.Collator("de").compare));console.log([...letters].sort(new Intl.Collator("sv").compare));console.log("a".localeCompare("B", "en"));0 00 -1[ 'a', 'ä', 'z' ][ 'a', 'z', 'ä' ]-1compare returns 0 for strings that are equal under the chosen sensitivity, a negative number when the first comes first, and a positive number otherwise. German puts ä next to a, and Swedish puts it after z. The method a.localeCompare(b, locale, options) takes the same locale and options and is the quick form for one comparison. When you sort a long list, build one collator and reuse it. A call to localeCompare without a locale uses the machine’s default, which makes the order differ between computers, for the same reason as in the habits at the top of this lesson.
Names of languages and regions
Intl.DisplayNames turns codes into names in the language of your choice. It saves you from keeping tables of country and language names for a settings page or a picker.
const regions = new Intl.DisplayNames("en", { type: "region" });const languages = new Intl.DisplayNames("de", { type: "language" });const currencies = new Intl.DisplayNames("en", { type: "currency" });const frenchRegions = new Intl.DisplayNames("fr", { type: "region" });
console.log(regions.of("DE"), regions.of("JP"));console.log(languages.of("en"), languages.of("ja"));console.log(currencies.of("EUR"), frenchRegions.of("US"));Germany JapanEnglisch JapanischEuro États-UnisThe type is one of region, language, script, currency and calendar, and of takes the matching code. The first argument is the language of the answer, so the German formatter names English Englisch.
Common pitfalls
- Leaving out the locale. The result then depends on the machine. Pass one explicitly, or pass
undefinedonly when you really mean “the user’s own”. - Leaving out
timeZone. The same moment prints differently on different machines. - Comparing formatted text across environments. Different Node builds and browsers carry different locale data. The result for less common locales and styles can change between versions. Test your formatting logic, not the exact wording.
- Assuming spaces in output are normal spaces. Many locales use no-break spaces. Compare with care.
- Formatting too early.
Intloutput is text. Keep numbers as numbers and dates as dates until the last step. - Building formatters in a loop. Create one and reuse it.
- Sorting text with plain
sort(). It orders by code unit, soZcomes beforeaandéafterz. Use a collator. - Using
toFixedfor display of money. Currency digits and signs depend on the currency and the locale. UseNumberFormat.
Exercises
Print a receipt for 2 teas at 3.50 and 1 cake at 4.25, sold at 2026-03-14T15:30:00Z, in three locales: en-US with USD and the zone America/New_York, de-DE with EUR and Europe/Berlin, and ja-JP with JPY and Asia/Tokyo. Each receipt prints the locale name, the date in the long style in that zone, one line per item as 2 x tea: <price>, and a Total: line. The expected output has the dates March 14, 2026, 14. März 2026 and 2026年3月15日 (Tokyo is already on the 15th), the prices $7.00, 7,00 € and ¥7, and the totals $11.25, 11,25 € and ¥11.
Show solution
One formatter per kind is created inside receipt, and the date formatter gets the zone. The yen has no decimals, so 4.25 shows as ¥4. This is exactly what the formatter is for: the arithmetic uses the plain numbers, and only the display changes.
const items = [ { name: "tea", qty: 2, price: 3.5 }, { name: "cake", qty: 1, price: 4.25 },];const sale = new Date("2026-03-14T15:30:00Z");
function receipt(locale, currency, timeZone) { const money = new Intl.NumberFormat(locale, { style: "currency", currency }); const day = new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone }); const lines = [locale, day.format(sale)]; let total = 0; for (const { name, qty, price } of items) { lines.push(`${qty} x ${name}: ${money.format(qty * price)}`); total += qty * price; } lines.push(`Total: ${money.format(total)}`); return lines.join("\n");}Print the same receipt for three locales:
console.log(receipt("en-US", "USD", "America/New_York"));console.log(receipt("de-DE", "EUR", "Europe/Berlin"));console.log(receipt("ja-JP", "JPY", "Asia/Tokyo"));en-USMarch 14, 20262 x tea: $7.001 x cake: $4.25Total: $11.25de-DE14. März 20262 x tea: 7,00 €1 x cake: 4,25 €Total: 11,25 €ja-JP2026年3月15日2 x tea: ¥71 x cake: ¥4Total: ¥11Write ordinal(n) that returns 1st, 2nd, 3rd, 4th and so on, using Intl.PluralRules with type: "ordinal". Print the results for 1, 2, 3, 4, 11, 12, 21, 22 and 103 on one line separated by spaces. The expected output is 1st 2nd 3rd 4th 11th 12th 21st 22nd 103rd.
Show solution
The rules return one for 1, 21 and 101, two for 2 and 22, few for 3, and other for 11, 12 and 4. The table turns the category into a suffix.
const rules = new Intl.PluralRules("en", { type: "ordinal" });const suffixes = { one: "st", two: "nd", few: "rd", other: "th" };
function ordinal(n) { return `${n}${suffixes[rules.select(n)]}`;}
console.log([1, 2, 3, 4, 11, 12, 21, 22, 103].map(ordinal).join(" "));1st 2nd 3rd 4th 11th 12th 21st 22nd 103rdWrite fromNow(seconds) that takes a number of seconds (negative for the past) and returns the text from Intl.RelativeTimeFormat with numeric: "auto", using the largest unit from day, hour, minute and second that fits. Print the results for -45, 7200, -259200 and 86400. The expected output is 45 seconds ago, in 2 hours, 3 days ago and tomorrow.
Show solution
The units are listed from large to small, and the first one the value reaches is used. Dividing and rounding gives the count. 86400 seconds is one day, which the auto option turns into tomorrow.
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });const units = [ ["day", 86400], ["hour", 3600], ["minute", 60], ["second", 1],];
function fromNow(seconds) { for (const [unit, size] of units) { if (Math.abs(seconds) >= size || unit === "second") { return rtf.format(Math.round(seconds / size), unit); } }}
for (const s of [-45, 7200, -259200, 86400]) { console.log(fromNow(s));}45 seconds agoin 2 hours3 days agotomorrowWrite summary(files) that returns No files for an empty array, otherwise the count with the right noun and the names joined as a list. Use PluralRules for the noun and ListFormat for the list. Print the results for [], one file, two files and three files, named a.txt, b.txt and c.txt. The expected output is No files, 1 file: a.txt, 2 files: a.txt and b.txt and 3 files: a.txt, b.txt, and c.txt.
Show solution
PluralRules decides between file and files, and ListFormat supplies the commas and the final “and”. Zero is handled first, because it needs its own wording.
const plural = new Intl.PluralRules("en");const list = new Intl.ListFormat("en");
function summary(files) { const word = plural.select(files.length) === "one" ? "file" : "files"; if (files.length === 0) { return "No files"; } return `${files.length} ${word}: ${list.format(files)}`;}
console.log(summary([]));console.log(summary(["a.txt"]));console.log(summary(["a.txt", "b.txt"]));console.log(summary(["a.txt", "b.txt", "c.txt"]));No files1 file: a.txt2 files: a.txt and b.txt3 files: a.txt, b.txt, and c.txtSummary
Intlformatters turn data into text for a locale:NumberFormat,DateTimeFormat,ListFormat,PluralRules,RelativeTimeFormatand, from Node 24,DurationFormat.- Always pass a locale, and for dates always pass a
timeZone, so the output does not depend on the machine. - Currency, percent, compact and unit styles are options of
NumberFormat. Currency decides the decimals, the locale decides the layout. PluralRulesgives a category, and you supply the words.RelativeTimeFormatneeds the unit chosen by your code.Intl.Collatorsorts text by the rules of a language, withnumeric: truefor numbers inside text andsensitivityto ignore case or accents.Intl.DisplayNamesnames languages, regions and currencies.- Create a formatter once and reuse it. The
toLocale...Stringmethods are shortcuts with the same options. - Output is for people. Keep real values as numbers and dates, and do not compare formatted text across environments.
Next: Date, the legacy API.