Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #19

Localization with JavaScript Intl: Turkish Sorting with Collator, PluralRules, ListFormat, Segmenter, DisplayNames and DurationFormat

Mert Özen Nov 15, 2026 13 min 5 views
Localization with JavaScript Intl: Turkish Sorting with Collator, PluralRules, ListFormat, Segmenter, DisplayNames and DurationFormat

Use Intl.Collator("tr") for Turkish sorting, PluralRules for singular/plural text, ListFormat for "Dune, Emma, and The Hobbit", Segmenter for character limits in reviews with emoji and DurationFormat for reading time. With Bookstore examples we show each one, the browser differences and why caching instances matters, with real output.

Version note: The code in this post targets ES2026 (ECMAScript 2026) and current browsers (Chrome, Edge, Firefox, Safari). The examples were run with Node.js 24.22 and in the Chromium 158 and WebKit engines of Playwright 1.65.

Predict First

Three lines, three small questions. What does the console print? Make a guess before you look at the answers:

console.log(["Volume 10", "Volume 2", "Volume 1"].sort());
console.log("İSTANBUL".toLowerCase() === "istanbul");
console.log("👍🏽".length);

The first prints ["Volume 1", "Volume 10", "Volume 2"], because the default sort compares characters, not numbers. The second is false: toLowerCase turns the capital İ into "i" plus a combining dot, so the result is a string of 9 characters, not 8. The third is 4: you see a single thumbs-up on screen, but the string holds four UTF-16 units. All three are solved by Intl. In this post we finish the work we started with toLocaleUpperCase, Intl.NumberFormat and localeCompare in the Basics series posts on strings, numbers and arrays, and continued with Intl.DateTimeFormat and RelativeTimeFormat in the previous post.

The rules of Intl are defined in ECMA-402, the Intl standard, whose latest edition is the 13th, published in June 2026. Which word or which order a language uses, though, comes from the language data inside the engine. That data comes from CLDR, the shared Unicode data set of sorting, plural and formatting rules, and most engines use it through ICU, the C/C++ library that implements those rules. The Node.js 24.22 used in this post ships with ICU 78.3 and CLDR 48.

Turkish Sorting with Intl.Collator

In the catalog of Bookstore, the online bookstore used as the shared example across this site's series, we're building a "By author" sort for Turkish authors. First the default sort, then Turkish and English rules:

const authors = ["Orhan Pamuk", "Çetin Altan", "Ömer Seyfettin", "İlhan Berk",
  "Işıl Özgentürk", "Sait Faik", "Şebnem İşigüzel", "Cemal Süreya"];

console.log([...authors].sort());
// ["Cemal Süreya", "Işıl Özgentürk", "Orhan Pamuk", "Sait Faik", "Çetin Altan", "Ömer Seyfettin", "İlhan Berk", "Şebnem İşigüzel"]

const trCollator = new Intl.Collator("tr");
console.log([...authors].sort(trCollator.compare));
// ["Cemal Süreya", "Çetin Altan", "Işıl Özgentürk", "İlhan Berk", "Orhan Pamuk", "Ömer Seyfettin", "Sait Faik", "Şebnem İşigüzel"]

console.log([...authors].sort(new Intl.Collator("en").compare));
// ["Cemal Süreya", "Çetin Altan", "İlhan Berk", "Işıl Özgentürk", "Ömer Seyfettin", "Orhan Pamuk", "Sait Faik", "Şebnem İşigüzel"]

The default sort looks at UTF-16 code units; Ç, Ö, İ and Ş are all greater than every ASCII letter, so they fall to the end of the list. Intl.Collator("tr") knows the Turkish alphabet: Ç comes after C, İ after I, Ö after O. English rules also put Ç next to C, but treat the difference as secondary. They see Ö as an accented O, so "Ömer" moves ahead of "Orhan". They also treat I and İ as the same letter and look at the following letters ("l" comes before "ş"). Same data, two correct but different orders. The language of the content decides which rules you use, not the language of the UI: even in Bookstore's English UI, a list of Turkish authors should follow the Turkish alphabet.

Because compare is a bound function, you can pass it straight to sort; it gives the same result as a.localeCompare(b, "tr"). The difference is that localeCompare resolves the locale settings again on every call; we'll look at what that costs in the Production Notes section.

Two Collator options cover most everyday work. numeric: true compares the digits inside a string as numbers. sensitivity decides which kind of difference makes two strings "different": "base" looks only at the base letter, "accent" also at accents, "case" also at upper and lower case; the default "variant" looks at everything:

const volumes = ["Volume 10", "Volume 2", "Volume 1"];
const numericCollator = new Intl.Collator("en", { numeric: true });
console.log(volumes.toSorted(numericCollator.compare)); // ["Volume 1", "Volume 2", "Volume 10"]

const trBase = new Intl.Collator("tr", { sensitivity: "base" });
const enBase = new Intl.Collator("en", { sensitivity: "base" });

console.log(trBase.compare("Dune", "dune"));         // 0
console.log(trBase.compare("istanbul", "İSTANBUL")); // 0
console.log(trBase.compare("çeşme", "cesme"));       // 1
console.log(enBase.compare("çeşme", "cesme"));       // 0

The last two lines matter. In English rules, ç is an accented c; "base" ignores it and the two words come out equal. In Turkish, ç is a separate letter, not an accent; even with "base", "çeşme" and "cesme" differ. If you want a customer without a Turkish keyboard to find a book called "Çeşme" by typing "cesme", the Turkish collator won't do it; you need to search with English rules or add a separate normalization step.

A collator compares two strings from start to end; it doesn't answer "does this contain that". The most common approach in catalog search is to lowercase both sides with the same language's rules and check with includes:

function matchesQuery(title, query) {
  return title.toLocaleLowerCase("tr").includes(query.toLocaleLowerCase("tr"));
}

console.log(matchesQuery("Üç İstanbul", "İSTANBUL"));            // true
console.log("Üç İstanbul".toLowerCase().includes("istanbul"));  // false

This is also the answer to the second line of the guessing game: toLowerCase works without language rules, so it turns İ into "i" and a combining dot, and "istanbul" never appears in the searched text.

Intl.PluralRules: "1 Book", "3 Books"

The text under the cart badge changes with the number of books in English: "1 book", "3 books". Solving this with count === 1 is enough for English, but plural rules in other languages can be far more involved. Intl.PluralRules tells you which plural category a number falls into in a language; you pick the text:

const cartMessages = {
  en: { one: "{count} book in your cart", other: "{count} books in your cart" },
  tr: { one: "Sepetinizde {count} kitap var", other: "Sepetinizde {count} kitap var" }
};
const pluralRules = { en: new Intl.PluralRules("en"), tr: new Intl.PluralRules("tr") };

function cartLabel(lang, count) {
  const category = pluralRules[lang].select(count);
  return cartMessages[lang][category].replace("{count}", count);
}

console.log(cartLabel("en", 1)); // "1 book in your cart"
console.log(cartLabel("en", 3)); // "3 books in your cart"
console.log(cartLabel("en", 0)); // "0 books in your cart"
console.log(cartLabel("tr", 3)); // "Sepetinizde 3 kitap var"
console.log(pluralRules.tr.resolvedOptions().pluralCategories); // ["one", "other"]

The last line may be surprising: in Turkish a noun after a number takes no plural suffix ("3 kitap"), yet CLDR defines two categories for Turkish too, one and other. That's why we put the same text under both categories in the Turkish messages. Keeping the category as a key in the message object, rather than in an if in the code, lets a translator enter a text per category.

type: "ordinal" gives ordinal numbers. For labels like "1st" and "22nd" in the bestseller list:

const enOrdinal = new Intl.PluralRules("en", { type: "ordinal" });
const suffixes = { one: "st", two: "nd", few: "rd", other: "th" };
const rankLabel = (n) => `${n}${suffixes[enOrdinal.select(n)]}`;

console.log([1, 2, 3, 4, 11, 12, 13, 21, 22, 23].map(rankLabel));
// ["1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "23rd"]

console.log(new Intl.PluralRules("tr", { type: "ordinal" }).resolvedOptions().pluralCategories);
// ["other"]

You didn't write that 11, 12 and 13 are exceptions in English; the rules know it. Turkish has a single category; in the Turkish UI you write ordinals with your own rule, such as "1." or "1'inci", because the suffix depends on how the number is pronounced.

Intl.ListFormat and Intl.DisplayNames

If you build the sentence "Dune, Emma ve The Hobbit" in the order summary with join(", "), you need extra code to turn the last comma into "ve"; in English the Oxford comma joins the picture too. Intl.ListFormat does this per language:

const cartTitles = ["Dune", "Emma", "The Hobbit"];

console.log(new Intl.ListFormat("tr").format(cartTitles));    // "Dune, Emma ve The Hobbit"
console.log(new Intl.ListFormat("en").format(cartTitles));    // "Dune, Emma, and The Hobbit"
console.log(new Intl.ListFormat("en-GB").format(cartTitles)); // "Dune, Emma and The Hobbit"
console.log(new Intl.ListFormat("tr", { type: "disjunction" }).format(["Dune", "Emma"])); // "Dune veya Emma"
console.log(new Intl.ListFormat("en", { type: "disjunction" }).format(["Dune", "Emma"])); // "Dune or Emma"

American English puts a comma before the final "and", British English doesn't; the language data knows that as well. type: "unit" exists for unit lists like "2 hr 15 min".

Intl.DisplayNames turns language, region and currency codes into readable names. You use it to show each language's name in that language in the language switcher, for the country list in the delivery form and for the currency name next to a price. Intl.Locale splits a language tag into its parts:

const languageCodes = ["tr", "en"];
console.log(languageCodes.map((code) => new Intl.DisplayNames([code], { type: "language" }).of(code)));
// ["Türkçe", "English"]

const trRegions = new Intl.DisplayNames(["tr"], { type: "region" });
console.log(["TR", "DE", "US"].map((code) => trRegions.of(code)));
// ["Türkiye", "Almanya", "Amerika Birleşik Devletleri"]

console.log(new Intl.DisplayNames(["en"], { type: "currency" }).of("TRY")); // "Turkish Lira"
console.log(new Intl.DisplayNames(["tr"], { type: "currency" }).of("TRY")); // "Türk lirası"
console.log(new Intl.DisplayNames(["en"], { type: "region" }).of("XX"));    // "XX"
console.log(new Intl.DisplayNames(["en"], { type: "region", fallback: "none" }).of("XX")); // undefined

const trLocale = new Intl.Locale("tr-TR");
console.log(trLocale.language, trLocale.region, trLocale.baseName); // tr TR tr-TR

For an unknown code, the default behavior is to return the code itself. With fallback: "none" you get undefined and handle the "I don't know this code" case yourself. Using this API instead of keeping country and language names in a hand-made JSON file also keeps your page smaller.

Intl.Segmenter: The Characters the User Sees

We're putting a 500-character limit on book reviews. In the Basics series post on strings we saw that "😀".length is 2 and that for...of counts it as one character. for...of counts code points; with marks made of several code points, such as skin tones, family emoji and flags, it gets it wrong too. The unit the user sees as one character is called a grapheme. Intl.Segmenter splits text at grapheme, word or sentence boundaries:

const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });

function visibleLength(text) {
  let count = 0;
  for (const _ of graphemes.segment(text)) count++;
  return count;
}

const review = "Great book 👍🏽";
console.log(review.length, [...review].length, visibleLength(review)); // 15 13 12

const family = "👨‍👩‍👧🇹🇷";
console.log(family.length, [...family].length, visibleLength(family)); // 12 7 2

The family emoji is five code points (three people with two joiner characters between them) and the flag is two; on screen you see two marks. If you set the limit with length, a customer who uses emoji gets a "too long" warning before writing 500 characters. Truncating has the same problem: if slice cuts through the middle of an emoji, a broken mark stays on screen. Truncate by graphemes:

function truncate(text, max) {
  const parts = Array.from(graphemes.segment(text), (s) => s.segment);
  return parts.length <= max ? text : parts.slice(0, max).join("") + "…";
}

const shortReview = "Loved it 👨‍👩‍👧 so much";
console.log(JSON.stringify(shortReview.slice(0, 10))); // "Loved it \ud83d"
console.log(truncate(shortReview, 10));                 // "Loved it 👨‍👩‍👧…"

For a word count you use granularity: "word". Every segment has an isWordLike property; it's false for whitespace and punctuation segments:

const trWords = new Intl.Segmenter("tr", { granularity: "word" });
const wordCount = (text) => [...trWords.segment(text)].filter((s) => s.isWordLike).length;

console.log(wordCount("Dune'u iki günde bitirdim, harika!")); // 5

"Dune'u" was counted as one word; split(" ") would have returned "bitirdim," and "harika!" with their punctuation attached. According to the web-features explorer, Intl.Segmenter became Baseline in April 2024 with Firefox 125, and it has been widely available since October 2026.

Intl.DurationFormat: Reading Time

We show an estimated reading time on the book page. Splitting minutes into hours and minutes is easy; building the right text for each language, like "2 sa. 15 dk." and "2 hr, 15 min", is the job of Intl.DurationFormat:

const shortDuration = {
  tr: new Intl.DurationFormat("tr", { style: "short" }),
  en: new Intl.DurationFormat("en", { style: "short" })
};

function readingTime(lang, totalMinutes) {
  const duration = { hours: Math.floor(totalMinutes / 60), minutes: totalMinutes % 60 };
  return shortDuration[lang].format(duration);
}

console.log(readingTime("tr", 135)); // "2 sa. 15 dk."
console.log(readingTime("en", 135)); // "2 hr, 15 min"
console.log(readingTime("en", 60));  // "1 hr"
console.log(new Intl.DurationFormat("en").format({ minutes: 135 })); // "135 min"
console.log(new Intl.DurationFormat("tr", { style: "long" }).format({ hours: 12, minutes: 40 })); // "12 saat 40 dakika"
console.log(new Intl.DurationFormat("en", { style: "digital" }).format({ hours: 2, minutes: 15 })); // "2:15:00"

Three details. First, DurationFormat doesn't convert units into each other: it writes 135 minutes as "135 min", and splitting into hours is up to you. Second, units with a value of zero are left out by default ("1 hr"). Third, format takes a plain object; it also takes a Duration object from Temporal, which we previewed in the previous post. Values must be integers; { minutes: 2.5 } throws a RangeError.

style can be "long", "short" (the default), "narrow" or "digital". Here I saw two browser differences. In the Turkish "narrow" style, Chromium 158 and Node.js wrote "2s 15d" while WebKit wrote "2sa 15d". In the "short" style the text looks the same, but WebKit put U+202F, a narrow no-break space, between the number and the unit instead of a normal space. According to the web-features explorer, Intl.DurationFormat became Baseline in March 2025 with Firefox 136 (Chrome 129, Safari 16.4); it's expected to be widely available in September 2027. If you support older browsers, prepare a simple fallback text behind a typeof Intl.DurationFormat === "function" check.

Production Notes

Creating an Intl instance costs much more than using it: the engine resolves the language tag, loads the CLDR data and prepares the options. I sorted 40,000 book titles three ways:

const sampleWords = ["Dune", "Emma", "Middlemarch", "Hobbit", "Frankenstein", "Pride", "Prejudice", "Persuasion", "Ulysses", "Walden"];
const manyTitles = Array.from({ length: 40000 }, (_, i) => `${sampleWords[i % 10]} ${sampleWords[(i * 7) % 10]} ${i}`);

console.time("localeCompare");
[...manyTitles].sort((a, b) => a.localeCompare(b, "tr"));
console.timeEnd("localeCompare");

console.time("one Collator");
[...manyTitles].sort(trCollator.compare);
console.timeEnd("one Collator");

console.time("new Collator per comparison");
[...manyTitles].sort((a, b) => new Intl.Collator("tr").compare(a, b));
console.timeEnd("new Collator per comparison");

In my runs, on Node.js 24 one Collator took 12–23 ms, localeCompare(b, "tr") 27–34 ms and a new Collator per comparison 450–490 ms. In Chromium 158 I measured 11–19, 27–33 and 347–370 ms respectively; in WebKit 10–12, 10–13 and 221–224 ms. So the gap between localeCompare and a single Collator is less than twofold in V8 and almost nothing in WebKit, but creating a new instance per comparison is at least 20 times slower in every engine. MDN's localeCompare page also recommends creating an Intl.Collator and using its compare method when sorting large arrays.

If a component writes new Intl.NumberFormat(...) on every render, you pay that cost on every render. The fix is to create instances once per language and options and keep them:

const intlCache = new Map();

function getIntl(Ctor, locale, options = {}) {
  const key = `${Ctor.name}|${locale}|${JSON.stringify(options)}`;
  let instance = intlCache.get(key);
  if (!instance) {
    instance = new Ctor(locale, options);
    intlCache.set(key, instance);
  }
  return instance;
}

console.log(getIntl(Intl.Collator, "tr") === getIntl(Intl.Collator, "tr")); // true
console.log(getIntl(Intl.ListFormat, "en").format(["Dune", "Emma"]));     // "Dune and Emma"

The second topic is the version of the language data. Outputs can change as CLDR is updated; an abbreviation that reads "sa." in one browser may change in the next release, and Node.js and the browser may carry different ICU versions. So don't compare formatted text with a fixed string in tests; compare it with the result of the same Intl call, or check only the number and the order. If you render HTML on the server, pass the language explicitly ("tr") and don't rely on the default: the server's language isn't the customer's.

Third, choosing the language. navigator.languages gives you the user's preference, but on a bilingual site where the language is already chosen in the URL or the settings, give that language to Intl. If the browser's language and the page's language differ, the page text shows up in one language and the dates in another.

Common Pitfalls

Using toLowerCase() for Turkish search. A search for "İSTANBUL" won't find "Üç İstanbul", because the language-neutral rule turns İ into "i" and a separate dot. When the content's language is known, use toLocaleLowerCase("tr").

Setting a character limit with string.length. length counts UTF-16 units; a review with emoji looks "too long" well below the limit, and text cut with slice ends with a broken mark. Use Intl.Segmenter for the characters the user sees. The server-side limit must count by the same rule, otherwise the API rejects a review the browser accepted.

Creating a new Intl instance on every render or every comparison. In the measurement above it made sorting more than 20 times slower. Create the instance once outside, or cache it.

Expecting "ç = c" in Turkish with sensitivity: "base". In Turkish rules, ç, ğ, ı, ö, ş and ü are separate letters. If you want a search that tolerates keyboard differences, write it as a separate rule.

Sorting with the UI language. If you sort a list of Turkish titles with "en" rules in the English UI, authors starting with Ö get mixed in with those starting with O. The sorting language is the content's language.

A Small Experiment

★☆☆ Add "çağan", "Çağla" and "cem" to the authors list and sort it with new Intl.Collator("tr", { caseFirst: "upper" }). How are upper and lower case names that start with the same letter ordered? What do you get without caseFirst?

★★☆ Extend cartLabel: have it append the titles in the cart with Intl.ListFormat and produce "3 books in your cart: Dune, Emma, and The Hobbit" and "Sepetinizde 3 kitap var: Dune, Emma ve The Hobbit". Get every Intl instance through getIntl.

★★★ Build a review field: on the input event, update a "12/500" counter with visibleLength and disable the submit button past 500. Paste a family emoji and a flag into the text and verify that the counter counts graphemes, not length. Write the counter text with Intl.NumberFormat for the selected language (tr/en) as well.

And with that, the Intermediate series is done. From modern syntax to closures, from promises to Fetch, from modules to dates and bilingual interfaces, you've seen every corner of the language you use in day-to-day work. In the Advanced series we go under the hood: how the prototype chain really works, the anatomy of the event loop with its microtask and render steps, memory management, measuring performance, Web Workers, Web Components, Temporal and, last, animation and scroll APIs with element.animate.