Why toLowerCase() Breaks Case-Insensitive Comparison: Unicode Case Folding

'straße' and 'STRASSE' differ after toLowerCase(). Case conversion vs case folding, the Turkish i, simple folding in regex /iu, and normalization, per Unicode 17.0 and ECMAScript 2026.

DevelopmentUnicode

A common check:

a.toLowerCase() === b.toLowerCase()

With straße and STRASSE it returns false: ß is already lowercase, so it stays. Uppercase both and ß becomes SS; they match. A direction-dependent result is unsuitable for comparison.

Case folding

Unicode §3.13 separates case conversion from folding for caseless matching. Default case folding uses the full mappings in CaseFolding.txt (status C and F), where ß folds to ss. Python’s 'straße'.casefold() gives 'strasse'. ECMAScript has no built-in String.prototype method for full default Unicode case folding.

The Turkish i

Turkish and Azeri pair ı with I and i with İ (tr and az rules in SpecialCasing.txt). Java’s toLowerCase() uses the default locale; its docs warn that a Turkish locale turns "TITLE" into "tıtle" and recommend Locale.ROOT for identifiers and protocol keys. JavaScript’s toLowerCase() ignores locale; toLocaleLowerCase("tr") gives "tıtle".

Regex /iu

With i plus u or v, ECMAScript maps each character through the simple or common mappings in CaseFolding.txt (status S and C). Each maps to one code point, so ß stays ß, while the Kelvin sign K (U+212A) folds to k. So /^straße$/iu.test("STRASSE") is false and /^k$/iu.test("\u212A") is true.

Normalization

Å (U+00C5) and A + U+030A fold to å and a + U+030A; folding doesn’t preserve normalization forms. Unicode’s canonical caseless match (D145) compares NFD(toCasefold(NFD(X))) on both sides. See NFC and NFD in Why character counts differ between tools.

Unicode §5.18 describes storing a folded copy next to the original for comparison. Examples ran on Node.js 24.13.0 and Python 3.14.7 (Unicode 16.0 data; same mappings in 17.0).

Sources

Links and content were checked on 7 October 2026.

Back to the blog