toLowerCaseで大文字小文字を無視した比較がずれる理由:Unicodeのケースフォールディング
'straße'と'STRASSE'はtoLowerCase()で比べると一致しません。大文字小文字変換とケースフォールディングの違い、トルコ語のi、正規表現/iuの単純フォールディング、正規化をUnicode 17.0とECMAScript 2026に沿って整理しました。
大文字小文字を区別せずに文字列を比べるとき、よくこう書きます。
a.toLowerCase() === b.toLowerCase()
かなや漢字に大文字小文字はありませんが、ユーザー名やタグにはラテン文字が混ざります。straßeとSTRASSEを渡すとfalseです。STRASSEはstrasseになる一方、ßはもともと小文字なのでそのまま残ります。両方を大文字にするとßがSSになり、一致します。向きによって答えが変わるなら、比較に向いた操作ではありません。
ケースフォールディング
Unicode標準の3.13節は、大文字小文字変換とケースフォールディングを分けています。フォールディングの主な目的は、大文字小文字を無視した照合です。既定のケースフォールディングはCaseFolding.txtのステータスCとFの完全(full)マッピングを使い、ßはssに畳まれます。Pythonのstr.casefold()がこの操作で、'straße'.casefold()は'strasse'です。ECMAScriptには、Unicodeの完全な既定ケースフォールディングを実装する組み込みString.prototypeメソッドがありません。
トルコ語のi
トルコ語とアゼルバイジャン語ではıとI、iとİが対になり、SpecialCasing.txtはこれをtrとazの条件で定めています。JavaのtoLowerCase()は既定のロケールに従います。公式ドキュメントは、トルコ語ロケールでは"TITLE".toLowerCase()が"tıtle"になると注意し、識別子やプロトコルのキーにはLocale.ROOTを使うよう勧めています。JavaScriptのtoLowerCase()はロケールに依存せず、toLocaleLowerCase("tr")を使うと"tıtle"になります。
正規表現の/iu
iにuかvのフラグを加えると、ECMAScriptは各文字をCaseFolding.txtの単純(simple)または共通(common)マッピング、つまりステータスSとCで変換してから比較します。このマッピングは常に1つのコードポイントを返すため、ßはssにならず、ケルビン記号K(U+212A)はkに畳まれます。そのため/^straße$/iu.test("STRASSE")はfalse、/^k$/iu.test("\u212A")はtrueです。
正規化
Å(U+00C5)とA+U+030Aはフォールディングするとそれぞれåとa+U+030Aになり、フォールディングは正規化形式を保ちません。そこで標準は、正準等価での大文字小文字を無視した一致(D145)を、両側のNFD(toCasefold(NFD(X)))を比べるものとして定義しています。NFCとNFDは文字数がツールごとに違う理由で扱いました。
5.18節は、元の文字列はそのまま残し、フォールディングした値を別に保存して比較に使う方法を説明しています。上の実行結果はUnicode 16.0のデータを使うNode.js 24.13.0とPython 3.14.7で確認したもので、ここで使ったマッピングはUnicode 17.0でも同じです。
参考
- Unicode標準 17.0、3.13節 Default Case Algorithms
- Unicode標準 17.0、5.18節 Case Mappings
- CaseFolding.txt 17.0.0、SpecialCasing.txt 17.0.0
- ECMA-262 2026(第17版):22.1.3.26、22.1.3.28、22.2.2.7.3
- ECMA-402 2026(第13版):20.1.2.1
- Java SE 27
String.toLowerCase() - Python 3.14
str.casefold()
リンクと内容は2026年10月7日に確認しました。