Comparing & sorting text
Compare strings by code units, sort human text with localeCompare and Intl.Collator, handle locale-aware case conversion, and build case-insensitive search that respects accents.
- 01Explain the default orderSay why the default order puts capital letters and accented letters in surprising places.
- 02Pick a language-aware toolUse
localeComparefor one comparison, and oneIntl.Collatorfor a big sort. - 03Build a friendly searchChoose on purpose how to handle capitals and accents, instead of hoping
toLowerCase()works for every language.
People and JavaScript order text differently
A string is a piece of text, like "apple". JavaScript can compare any two strings. But by default it does not compare them like a dictionary does. It compares the numbers it stores for each piece of the text. These numbers are called UTF-16 code units. You met them in the Unicode & string internals lesson.
Code units are good for exact rules that machines follow. But they often give a strange order for names, titles, product lists, and search boxes. This lesson shows the tools that order text the way people expect.
This lesson builds on three earlier lessons: Comparisons, Sorting & copying safely, and Unicode & string internals. The Internationalization with Intl lesson comes later and goes further. You will still write the same kind of sort function. Only the part that compares two strings changes.
Text collation is a set of rules for ordering text. For two strings, it says whether the first comes before the second, after it, or counts as equal. The rules depend on the language and on the job, such as sorting or searching.
Every letter has a hidden number. A machine can sort names by those numbers. Your phone sorts your contacts in the order people expect for your language. JavaScript can do both.
- In real life: The hidden number behind each letter
- In JavaScript: A letter’s UTF-16 code unit
- In real life: Sorting names by those numbers
- In JavaScript: Default
<,>, andsort()on strings - In real life: Your phone’s contact order for your language
- In JavaScript:
localeComparewith a locale such as"de" - In real life: One saved setting for the whole contact list
- In JavaScript: One
Intl.Collator, made once and used again
By default, JavaScript compares code units
STEP THROUGHWhen both sides of < are strings, JavaScript reads them one code unit at a time, from the left. It stops at the first place where they differ. The string with the smaller number there comes first. Array sort() uses the same rule for strings when you don't give it a compare function.
console.log("a".charCodeAt(0)); // 97console.log("Z".charCodeAt(0)); // 90 console.log("Zebra" < "apple"); // truecharCodeAt(0) gives the code unit at position 0, which is the first letter. Line 1 prints 97 for small a. Line 2 prints 90 for capital Z. Every plain capital letter has a smaller number than every plain small letter. So line 4 prints true: Zebra comes before apple.
Z is 90 and small a is 97. 90 is smaller, so Zebra comes first, even though a dictionary puts apple first.Code-unit order is not a mistake. It is an exact rule for machines, not the rule of any language. It also explains why "10" < "9" is true. The first code units already differ: "1" comes before "9", so JavaScript never looks further.
const collator = new Intl.Collator("en");const result = collator.compare("apple", "banana"); console.log(result < 0); // true: apple comes before bananaconsole.log(Math.sign(result)); // -1, but only the sign is guaranteedA comparator is a compare function: it takes two values, a and b, and tells sort() which one comes first. It returns a number:
- A negative number means
acomes first. - Zero means they count as equal for this rule.
- A positive number means
bcomes first.
Line 1 makes an Intl.Collator, a tool that compares text with English rules. You'll learn it properly below. Line 2 compares apple with banana and gets a negative number, so line 4 prints true. Line 5 uses Math.sign, which turns any number into -1, 0, or 1. Only the sign of the result is promised. So don't write code that expects exactly -1 or 1.
Pick a way to compare, guess the printed order, then step through the run.
script
const sorted = [...words].sort();console.log(sorted.join(", "));const names = ["Zoe", "Asha", "Mina"];const sortedNames = [...names].sort((a, b) => a.localeCompare(b, "en")); console.log(names.join(", "));console.log(sortedNames.join(", "));[...names] uses the spread syntax. It makes a new array with the same items, called a shallow copy. sort() changes the array it is called on, so here it changes only the copy. Line 2 compares with localeCompare, which the next section explains. Line 4 prints "Zoe, Asha, Mina", the original order. Line 5 prints "Asha, Mina, Zoe", the sorted copy. Do this when another part of the page still needs the original order.
Use the default order for IDs, exact codes that programs send to each other, and other places where you want the exact machine rule. For names and labels that people read, use a comparison that knows the language.
localeCompare compares text like a dictionary
INTERACTIVEEvery string has a localeCompare method. You call it on one string and pass the other one: a.localeCompare(b, locale). A locale is a short code for a language, such as "en" for English or "de" for German.
localeCompare returns the same kind of number as a comparator. Negative means "comes before". Zero means "counts as equal here". Positive means "comes after". So a sort function can return it directly.
"äpfel".localeCompare("zebra", "de"); // negative"äpfel".localeCompare("zebra", "sv"); // positive[...names].sort((a, b) => a.localeCompare(b, "de"));const german = "äpfel".localeCompare("zebra", "de");const swedish = "äpfel".localeCompare("zebra", "sv"); console.log(german < 0); // true: German places ä near aconsole.log(swedish > 0); // true: Swedish places ä after zAlways pass the locale: for example "en" (English), "de" (German), "sv" (Swedish), or "tr" (Turkish). If you leave it out, JavaScript uses the default language of the browser or computer. That can be right for an app that follows the user's language settings. In examples and tests, write the locale, so the result is the same on every computer.
In both blocks, line 1 uses German rules and line 2 uses Swedish rules on the same two words. German sorts ä next to a. Swedish treats ä as its own letter near the end of the alphabet, after z. The words did not change. Only the rules did. In the first block, line 3 uses localeCompare as the compare function for a whole sort.
ä next to a. Swedish puts ä after z. So äpfel comes first in German and last in Swedish.localeCompare can also take a third argument with options. One option, sensitivity, says which differences count, such as capital letters or accents. The playground shows two of its settings at the bottom.
const words = ["zebra", "äpfel", "apfel", "åre", "öl"];const collator = new Intl.Collator(locale);const sorted = [...words].sort(collator.compare);apfeläpfelåreölzebraCompare a with á. With sensitivity: "base" the result is 0, which means equal. With sensitivity: "accent" it is -1, which means different.
English and German sort these accented words next to their plain letters. Pick sv to see the Swedish order.
Picture a phone setting for how closely to match contact names. When you search, a, A, and á could all count as the same letter. When you check spelling, the accent should count.
- In real life: Ignore capitals and accents
- In JavaScript:
sensitivity: "base" - In real life: Notice accents, ignore capitals
- In JavaScript:
sensitivity: "accent" - In real life: Notice capitals, ignore accents
- In JavaScript:
sensitivity: "case" - In real life: Notice capitals and accents
- In JavaScript:
sensitivity: "variant"
Intl.Collator: set the rules once, compare many times
SORTERlocaleCompare is handy for one or two comparisons. But a sort compares many pairs. For a sort, make one Intl.Collator. A collator is an object that holds one locale and one set of options.
A collator has a compare method. It is a ready-made comparator, so you can pass collator.compare straight to sort(). The rules are set up once, not again for every pair.
The table lists the options you'll use most. Read each row as: this option, what it changes, and a short example.
| Option | What does it change? | Example |
|---|---|---|
sensitivity: "base" | Capital letters and accents don't count. | In English, a, A, and á count as equal. |
sensitivity: "accent" | Capital letters don't count, but accents do. | a and A are equal; a and á are different. |
sensitivity: "case" | Accents don't count, but capital letters do. | a and á are equal; a and A are different. |
sensitivity: "variant" | Capital letters, accents, and other small differences all count. | a, A, and á are all different. |
numeric: true | A run of digits is compared as one whole number. | item9 sorts before item10. |
caseFirst | Says whether capital or small letters sort first. | "upper", "lower", or "false" (the language's own rule). |
"Zebra" < "apple"["banana", "apple", "Cherry"].sort()names.sort((a, b) => a.localeCompare(b, "de"))names.sort(new Intl.Collator("sv").compare)new Intl.Collator("en", { numeric: true })"a".charCodeAt(0) > "Z".charCodeAt(0)
Put each line of code under the rule it uses.
numeric: true helps most with file names. Without it, item10 sorts before item9. The first character that differs is 1 in one name and 9 in the other, and 1 comes first. With numeric: true, a run of digits is compared as one whole number, so 9 comes before 10.
1 comes before 9, so item10 lands before item9. On the right, numeric: true reads each run of digits as one whole number.Capital letters can depend on the language
INTERACTIVEtoUpperCase() turns text into capital letters. toLowerCase() turns it into small letters. Changing between capital and small letters is called case conversion. These two methods use rules that suit most languages. A few languages need their own rules. For those, use toLocaleUpperCase(locale) and toLocaleLowerCase(locale), and name the language.
"i".toLocaleUpperCase("tr"); // "İ""I".toLocaleLowerCase("tr"); // "ı""ß".toLocaleUpperCase("de"); // "SS"İıSSTurkish has an i with a dot and an i without one. German ß becomes SS in capitals. The locale methods let you say which language's rules to use.
tr or de, so the result doesn't depend on your browser's language.Turkish has two different i letters: i with a dot and ı without one. Each has its own capital. So with Turkish rules, the capital of i is İ, a capital I with a dot. German is different again: JavaScript turns ß into two capitals, SS.
const upperName = "Straße".toLocaleUpperCase("de"); console.log(upperName); // "STRASSE"console.log(upperName.length); // 7, not 6Line 1 makes the German capitals of Straße. Line 3 prints "STRASSE". Line 4 prints 7, one more than the 6 letters we started with. So one letter can become two.
This matters when you save or compare text after changing it. Don't assume each letter stays one letter. And don't write your own table of capital letters. Use these methods with the right locale.
Search that ignores capitals and accents
INTERACTIVEA case-insensitive search ignores the difference between capital and small letters. Beginners often write haystack.toLowerCase().includes(needle.toLowerCase()). Here haystack is the text you search in, and needle is the text the user typed. Both become small letters. Then includes checks whether the first contains the second. This works for simple English labels. It misses three things:
- Accents:
éandestill count as different. - Language rules for capitals, like the two Turkish i letters.
- Two ways of storing the same letter. You'll see this below.
To check whether two whole strings are equal, a collator with sensitivity: "base" is often clearer.
const collator = new Intl.Collator("en", { sensitivity: "base" }); collator.compare("Résumé", "resume") === 0; // true"Résumé" === "resume"; // falseLine 1 makes a collator that ignores capitals and accents. Its compare method returns 0 when two strings count as equal. So line 3 is true. Line 4 is false, because === only says yes when every code unit is the same.
A collator can't tell you whether one string contains another. It only compares whole strings. For a search box with a small list, a common way is to fold the text first. To fold text means to turn it into a plain form used only for matching, for example José into jose. Fold the text the user typed too, not just the list. The fold uses normalization, so that comes first.
const composed = "é";const decomposed = "e\u0301"; console.log(composed === decomposed); // falseconsole.log(composed.normalize("NFC") === decomposed.normalize("NFC")); // trueThe letter é can be stored in two ways:
- Line 1: as one character,
"é". - Line 2: as a plain
efollowed by a separate accent mark,\u0301. A separate mark like this is called a combining mark.
Both look the same on screen. But line 4 prints false, because === sees different code units.
Normalization rewrites text into one chosen form. normalize("NFC") joins a letter and its mark into one character where it can. Line 5 normalizes both sides, so it prints true. normalize("NFD") does the opposite: it splits letters from their marks. That helps when the next step removes the marks.
function foldForSearch(value) { return value .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLocaleLowerCase("en");} console.log(foldForSearch("José")); // "jose"console.log(foldForSearch("Søren")); // "søren"The foldForSearch function has three steps:
- Line 3 splits each letter from its accent marks with
normalize("NFD"). - Line 4 removes the marks.
/[\u0300-\u036f]/gis a regular expression, a pattern for finding text. It finds every common combining mark, andreplaceswaps each one for nothing. - Line 5 changes the text to small letters with English rules.
normalize("NFD") splits the accent off the e. replace drops the accent mark, and toLocaleLowerCase makes the letters small. The list still shows José; jose is only used to match.So line 8 turns José into jose. Line 9 leaves ø as it is. ø is its own letter, not an o with a separate mark, so there is no mark to remove. This simple fold works for a known list of names in Latin letters, the alphabet English uses. For many other languages, use a search tool built for them.
function foldForSearch(value) { return value .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLocaleLowerCase("en");} const names = ["José", "Soren", "Søren", "Zoë", "İpek", "Ipek"];const matches = names.filter((name) => foldForSearch(name).includes(foldForSearch(query)),);Your search after folding: jose
JoséYour search becomes "jose" after folding. 1 folded name contains it.
Where you’ll use this
You'll compare text anywhere people look through a list: contact names, countries, product titles, table columns, menus of commands, and file names. A safe pattern has three parts:
- Keep the original string to show on the screen.
- Make a compare or search helper for the job.
- Write the locale and the options in your code.
const collator = new Intl.Collator(userLocale, { sensitivity: "base", numeric: true,}); const visibleProducts = products .filter((product) => matchesSearch(product.name, query)) .sort((a, b) => collator.compare(a.name, b.name));Lines 1 to 4 make one collator from the user's locale, userLocale. Line 2 says to ignore capitals and accents. Line 3 says to compare digits as whole numbers. Line 7 keeps only the products that match the search. Line 8 sorts them by name with the collator.
For a big sort, make one Intl.Collator and use it for every pair. Then the locale and options are set up once, not for every pair. This is a good habit. It doesn't promise the same speed-up in every browser.
Take the locale from the user's profile or the app's settings when you can. That is usually better than the computer's default. Use code-unit comparison for IDs and exact codes. Use locale rules for text people read. Never use a friendly comparison to check permissions or whether a name is already taken: two different strings can count as equal.
Common misconceptions
- "Alphabetical order is the same everywhere." Languages put
ä,å, andöin different places. German putsänext toa. Swedish puts it afterz. - "
sort()knows my users' language." With no compare function,sort()uses code-unit order. - "
localeComparealways returns -1 or 1." Only the sign is promised: negative, zero, or positive. - "Making both sides small letters fixes search for everyone."
éandestill count as different, and language rules like the Turkish i are ignored. - "Equal for a collator means the strings are the same." With
sensitivity: "base",Résuméandresumecount as equal, but===saysfalse.
Practice exercises
5 EXERCISESWork out the answer in your head before you check. Type the exact order that the default sort prints.
const words = ["banana", "apple", "Cherry", "éclair"];
console.log(words.sort().join(", "));The default order is Cherry, apple, banana, éclair. Capital C comes first. The small-letter words come next. é has a bigger code unit than any plain English letter, so éclair is last.
Sort the names with German rules. Sort a copy, so the original array stays the same.
const names = ["Özil", "Oden", "Änne", "Anna"];
const collator = new Intl.Collator("de");
console.log([...names].sort(collator.compare).join(", "));const names = ["Özil", "Oden", "Änne", "Anna"];
const collator = new Intl.Collator("de");
console.log([...names].sort(collator.compare).join(", "));German rules put Änne right after Anna, and Özil right after Oden. So it prints Anna, Änne, Oden, Özil.
Make the file names sort the way a file picker does: 1, 2, 10.
const files = ["file10", "file2", "file1"];
const collator = new Intl.Collator("en", { numeric: true });
console.log([...files].sort(collator.compare).join(", "));const files = ["file10", "file2", "file1"];
const collator = new Intl.Collator("en", { numeric: true });
console.log([...files].sort(collator.compare).join(", "));The collator compares each run of digits as a whole number, so file2 comes before file10.
Read the fold-and-filter code, then predict which name matches zoe.
function fold(value) {
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
const names = ["Jose", "José", "Zoë"];
console.log(names.filter((name) => fold(name).includes(fold("zoe"))).join(", "));function fold(value) {
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
const names = ["Jose", "José", "Zoë"];
console.log(names.filter((name) => fold(name).includes(fold("zoe"))).join(", "));Zoë folds to zoe, so the search zoe matches it. The list still shows Zoë with its accent.
This code makes both strings small letters, but it still treats accents as different. Replace it with a collator check that ignores capitals and accents.
// Bug: accents are treated as different.
console.log("Résumé".toLowerCase() === "resume".toLowerCase());const collator = new Intl.Collator("en", { sensitivity: "base" });
console.log(collator.compare("Résumé", "resume") === 0);With sensitivity: "base", the collator counts the two strings as equal. compare returns 0, so compare(...) === 0 prints true.
Check your understanding
7 QUESTIONSQuestion 1 of 7When both values are strings, what does
<compare?Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconsole.log("Zebra" < "apple");Choose an answer to see the explanation.
Question 3 of 7You sort thousands of names on a page, all with the same rules. Which tool fits best?
Choose an answer to see the explanation.
Question 4 of 7What does this sort print?
Read the code, then predictconst c = new Intl.Collator("en", { numeric: true }); console.log(["item10", "item9", "item1"].sort(c.compare).join(", "));Choose an answer to see the explanation.
Question 5 of 7What does this print with Turkish rules?
Read the code, then predictconsole.log("i".toLocaleUpperCase("tr"));Choose an answer to see the explanation.
Question 6 of 7You want to check if two strings are equal, ignoring capitals and accents. Which check is clearest?
Choose an answer to see the explanation.
Question 7 of 7What does this search fold print?
Read the code, then predictconst folded = "Résumé".normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); console.log(folded);Choose an answer to see the explanation.
Key takeaways
- By default, JavaScript compares strings by their UTF-16 code units. The rule is exact and fast, but often not the order people expect.
- Use
localeCompareto compare one pair with a language's rules. - Make one
Intl.Collatorwhen you sort many strings with the same rules. - Capital letters can depend on the language: Turkish has two i letters, and German
ßbecomesSS. - A search that ignores capitals and accents needs clear choices: normalize the text, remove the marks, and pick a locale.
To compare text well, use the rule your users expect, not just the rule JavaScript uses by default.
Up next: Destructuring arrays.