~/blog

sort() puts Ángel after zorro, and localeCompare() misplaces ñ

published

#javascript#i18n#sorting

TL;DR

words.sort() orders strings by UTF-16 code unit, so 'Zoe' comes before 'ana', and 'Ángel', 'Óscar' and 'ñandú' all land after 'zorro'. Switching to a.localeCompare(b) fixes the accents and the case, but with no locale argument it uses whatever locale the runtime defaults to, and in English collation ñ is just an n with a mark, so 'ñandú' sorts before 'nube'. For Spanish text, sort with new Intl.Collator('es').compare, and name the locale everywhere the order matters.

The problem

One list of Spanish words, three common ways to sort it, three different answers. Run on Node 26.2.0 with ICU 78.3 and a default locale of en-US:

const words = ['zorro', 'Ángel', 'ana', 'Zoe', 'ñandú', 'nube', 'Óscar', 'oso'];

words.toSorted();
// ['Zoe', 'ana', 'nube', 'oso', 'zorro', 'Ángel', 'Óscar', 'ñandú']

words.toSorted((a, b) => a.localeCompare(b));
// ['ana', 'Ángel', 'ñandú', 'nube', 'Óscar', 'oso', 'Zoe', 'zorro']

words.toSorted(new Intl.Collator('es').compare);
// ['ana', 'Ángel', 'nube', 'ñandú', 'Óscar', 'oso', 'Zoe', 'zorro']

The first is obviously broken. The second looks right, and that is the dangerous one: it passes every test with English names and only goes wrong on the ñ. Only the third matches a Spanish dictionary, where ñ is its own letter between n and o.

Why it happens

The default sort compares code units, not letters

Without a compare function, sort() converts every element to a string and compares the strings by UTF-16 code unit. MDN gives the numeric version of the same trap: "80" sorts before "9". For letters, the code points decide everything:

CharacterCode pointWhere sort() puts it
ZU+005Abefore every lowercase letter
aU+0061after every uppercase ASCII letter
zU+007Alast of the ASCII letters
ÁU+00C1after z
ñU+00F1after z

So uppercase sorts before lowercase, and every accented Latin letter, uppercase or not, sorts after the entire unaccented alphabet. Nothing about this is locale-aware.

localeCompare uses a real collation, in someone else’s language

localeCompare() and Intl.Collator use language-aware collation (the Unicode Collation Algorithm, implemented by ICU). Accents and case become lower-priority differences, so 'Ángel' sits next to 'ana' and 'Zoe' next to 'zorro'. That part fixes the first result.

But collation rules are per language, and the two languages disagree on ñ. You can see it by asking each collator whether n and ñ are the same base letter:

new Intl.Collator('en', { sensitivity: 'base' }).compare('n', 'ñ'); // 0  — same letter, different mark
new Intl.Collator('es', { sensitivity: 'base' }).compare('n', 'ñ'); // -1 — different letters

English treats ñandú like nandu, and nandu comes before nube. Spanish treats ñ as a letter after n, so every ñ word follows every n word.

When you call localeCompare(b) without a locale, or new Intl.Collator() without one, MDN is explicit about what you get: the runtime’s default locale. On a server that is often en-US. In a browser it is the user’s language. The same function can therefore produce one order during server rendering and a different one in a Spanish-speaking visitor’s browser.

ApproachAccented lettersCaseñDepends on the runtime’s locale
sort()after zuppercase firstafter zno
localeCompare(), no localenext to the base letterlowercase firstdepends: inside n for Englishyes
Intl.Collator('es').comparenext to the base letterlowercase firstafter nno

What to do

Create one collator with an explicit locale and reuse it

const collator = new Intl.Collator('es');
names.sort(collator.compare);

compare is bound to its collator, so it can be passed straight to sort(). a.localeCompare(b, 'es') gives the same order, but MDN recommends the collator for big lists: when comparing large numbers of strings, create an Intl.Collator once and use its compare().

'es' and 'es-MX' produced the same order for this list. If you need the traditional Spanish order, where ch and ll sort as single letters, ICU exposes it as a named collation:

['chico', 'cuna', 'llama', 'luz'].sort(new Intl.Collator('es').compare);
// ['chico', 'cuna', 'llama', 'luz']

['chico', 'cuna', 'llama', 'luz'].sort(new Intl.Collator('es-u-co-trad').compare);
// ['cuna', 'chico', 'luz', 'llama']

Options that change real results

const files = ['archivo10.txt', 'archivo2.txt', 'archivo1.txt'];

files.sort();
// ['archivo1.txt', 'archivo10.txt', 'archivo2.txt']

files.sort(new Intl.Collator('es', { numeric: true }).compare);
// ['archivo1.txt', 'archivo2.txt', 'archivo10.txt']

['ana', 'Ana', 'ANA'].sort(new Intl.Collator('es', { caseFirst: 'upper' }).compare);
// ['ANA', 'Ana', 'ana']

new Intl.Collator('es', { ignorePunctuation: true }).compare('o-so', 'oso'); // 0

Search is where the Spanish rule bites back

sensitivity: 'base' is the usual setting for accent- and case-insensitive matching, and in Spanish it does fold á into a and A into a. It does not fold ñ into n, because in Spanish they are different letters:

new Intl.Collator('es', { usage: 'search', sensitivity: 'base' }).compare('nino', 'niño'); // -1
new Intl.Collator('en', { usage: 'search', sensitivity: 'base' }).compare('nino', 'niño'); // 0

That is linguistically correct (cana and caña are different words), but if your search box should find niño when someone types nino on a keyboard without ñ, use a separate folding step for the match and keep the Spanish collator for the display order:

const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();

fold('Niño');     // 'nino'
fold('ÁRBOL');    // 'arbol'
fold('pingüino'); // 'pinguino'

NFD splits each accented letter into its base letter plus combining marks, and \p{M} removes the marks.

Caveats

References