JAVASCRIPT / STRINGS AND TEMPLATE LITERALS
String comparison and locale-aware ordering
Compare JavaScript strings correctly: predict what <, >, and === do with UTF-16 code units, and order or match text with Intl.Collator.
What you will learn
- Predict the result of <, >, and === on strings from UTF-16 code unit order
- Sort text with array.sort(new Intl.Collator(locale).compare) instead of the default
- Use sensitivity 'base' or 'accent' to match text while ignoring case or accents
- Test localeCompare's result with < 0, and normalize() before comparing with ===
Understanding String comparison and locale-aware ordering
A JavaScript string is a sequence of 16-bit code units, and <, >, <=, >= compare two strings the way you would compare two arrays of numbers: unit by unit from the left, the first difference decides the result, and if one string runs out first it is the smaller one, so "app" < "apple". Because the code units for uppercase ASCII letters (A is 65, Z is 90) all sit below the lowercase ones (a is 97, z is 122), "Zebra" < "apple" is true. An accented letter like é is code unit 233, above every ASCII letter, so it sorts after z. This ordering is fast and completely deterministic, which makes it right for cache keys, dedupe checks and binary search, and wrong for anything a person will read.
Human alphabet order is defined by collation, which compares strings on several levels at once: base letter first, then accent, then case. Intl.Collator and String.prototype.localeCompare implement the Unicode collation algorithm plus per-language tailorings, which is why the ordering of the same three words differs between German and Swedish: in de, ä is a variant of a, while in sv it is a distinct letter placed after z. Both functions return a negative number, zero, or a positive number, not necessarily -1, 0 or 1, so always compare the result against 0 rather than against -1.
Two practical consequences follow. Array.prototype.sort with no comparator converts elements to strings and compares code units, so alphabetizing anything the user sees means passing collator.compare as the comparator. And because the same visible letter can have more than one code unit spelling, é as U+00E9 or as e followed by U+0301, === can report false for two strings that render identically; normalize("NFC") collapses that difference for identity checks, while a collator already treats the two spellings as equal.
const words = ["Zebra", "apple", "Émile", "banana"];
// < and > compare UTF-16 code units: 'Z' is 90, 'a' is 97.
console.log("Zebra" < "apple");
console.log([...words].sort().join(" "));
// A collator compares letters the way the alphabet does.
const collator = new Intl.Collator("en");
console.log([...words].sort(collator.compare).join(" "));
// The same visible letter, spelled two ways.
const precomposed = "\u00E9"; // é
const decomposed = "e\u0301"; // e + combining acute accent
console.log(precomposed === decomposed);
console.log(precomposed.normalize("NFC") === decomposed.normalize("NFC"));
console.log(collator.compare(precomposed, decomposed));The relational operators and === compare raw UTF-16 code units, while Intl.Collator and localeCompare compare letters according to a specific language's alphabet.
Worked examples
Numbers inside strings
Shows why file10 sorts before file2 by default and how numeric collation fixes it.
const files = ["file10.txt", "file2.txt", "file1.txt"];
console.log(files.slice().sort().join(" "));
const natural = new Intl.Collator("en", { numeric: true });
console.log(files.slice().sort(natural.compare).join(" "));
console.log("10" < "9");Example explained
Line 1The default comparator stops at the first difference: after the shared "file", '1' (49) is below '2' (50), so file10.txt lands before file2.txt.
Line 2file1.txt beats file10.txt because at the first differing position '.' (46) is below '0' (48).
Line 3{ numeric: true } makes the collator read runs of digits as one number, so 2 sorts before 10.
Line 4"10" < "9" is true for the same reason as the first line: the operator never sees numbers, only code units.
The same words, two alphabets
Demonstrates that alphabetic order is a property of the locale, not of the strings.
const words = ["ähnlich", "zebra", "apfel"];
console.log(words.slice().sort(new Intl.Collator("de").compare).join(" "));
console.log(words.slice().sort(new Intl.Collator("sv").compare).join(" "));Example explained
Line 1collator.compare is returned already bound to its collator, so it can be handed straight to sort.
Line 2In de, ä has the same primary weight as a, so ähnlich and apfel are decided by their second letters, h before p.
Line 3In sv, ä is a separate letter placed after z, which pushes ähnlich to the end of the list.
Line 4Neither line is more correct than the other, so the locale you pass is part of the specification of the feature.
Matching while ignoring case or accents
Uses sensitivity levels to build accent-insensitive and case-insensitive equality tests.
const base = new Intl.Collator("en", { sensitivity: "base" });
console.log(base.compare("resume", "RÉSUMÉ") === 0);
console.log(base.compare("resume", "resumes") === 0);
const accent = new Intl.Collator("en", { sensitivity: "accent" });
console.log(accent.compare("resume", "RESUME") === 0);
console.log(accent.compare("resume", "résumé") === 0);Example explained
Line 1sensitivity: "base" compares base letters only, so both case and accents drop out and resume matches RÉSUMÉ.
Line 2Ignoring accents does not ignore extra letters: resume and resumes still differ, so compare returns a non-zero number.
Line 3sensitivity: "accent" keeps accent differences but still ignores case, which is why RESUME matches and résumé does not.
Line 4compare(a, b) === 0 is an equality test at that strength, not proof that the two strings hold the same code units.
Important notes
localeCompare and Intl.Collator with no locale argument use the runtime's default locale, so the same array can sort differently on two users' machines; pass an explicit locale wherever the order must be reproducible, including in tests.
A collator returning 0 does not mean the strings are equal, only that they are indistinguishable at the requested strength, so never use it to decide whether two values can share a Map key or a cache entry.
Common mistakes
Trying "apple" < "Banana", getting false, and concluding string comparison is broken; it is code-unit order, where B is 66 and a is 97, and the visible result is that every capitalized entry clumps ahead of the lowercase ones.
Writing if (a.localeCompare(b) === -1) instead of < 0; the spec only promises a negative number, so an engine that returns -2 or -3 silently makes the branch dead and leaves items unsorted or unswapped.
Calling a.localeCompare(b) or constructing a new Intl.Collator inside the comparator function; the locale and option resolution then runs on every one of the n log n comparisons, which turns a list of a few thousand names into a visible freeze.
Try it yourself
Change, predict, then run
In a browser console, sort ["Öl", "Zug", "Apfel", "Osterhase"] three ways: with no comparator, with new Intl.Collator("en").compare, and with new Intl.Collator("sv").compare. Two of the three lines come out identical, so work out why that agreement is a coincidence rather than the same rule at work.
Open the JavaScript workspaceCheck your understanding
What does console.log("a" < "B", new Intl.Collator("en").compare("a", "B") < 0) print?
- false true, because < ranks B (66) below a (97), while the collator ranks base letter a before b and uses case only as a tiebreak
- true true, because both compare alphabetically and ignore case
- false false, because both ultimately compare code units, where uppercase always wins
- true false, because < is case-insensitive while a collator is case-sensitive
Show answer
The relational operator compares code units, and every uppercase ASCII letter has a lower code unit than every lowercase one, so "a" < "B" is false. The collator compares base letters at its primary level, where a precedes b, so it returns a negative number and the second value is true. "true true" is tempting only if you assume < already knows the alphabet; it does not, and that difference is exactly why collators exist.