Intlによる国際化フォーマットの内部(CLDRとロケール解決)
通貨や日付の表示が環境で変わる謎が原理から解ける。BCP47ロケール解決とCLDRデータ依存、表示折りたたみの仕組みを押さえ、env依存の表示バグを根絶します。
- 1.Intlの各APIは渡したロケールをBCP47タグとして正規化し、ランタイムが持つCLDR由来のデータと突き合わせて利用可能なロケールを決める。要求が無ければ言語サブタグを段階的に削る言語ネゴシエーション(lookup)で代替を探す。
- 2.数値・日付・並べ替え・分割の規則は実装ではなくCLDR(Unicode共通ロケールデータ)に由来する。同じコードでもランタイムやCLDRのバージョン差で出力が変わり得るため、出力をハードコードで比較してはいけない。
- 3.Collatorは文字列を多段の重み列(照合要素)へ写像して比較し、Segmenterは書記素・単語・文の境界をUnicode規則で決める。どちらもロケールごとの規則差を吸収するのがCLDRの役割。
なぜ「同じコードで表示が変わる」のか
new Intl.NumberFormat("de-DE").format(1234.5) が 1.234,5 を返し、ブラウザやNode.jsのバージョンを変えると並べ替え順や日付の表記が微妙に変わる——これらは個別のバグではなく、Intlの出力がコードではなくロケールデータに由来するという設計から導かれる必然です。Intl(ECMAScript国際化API, ECMA-402)は自前で規則を持たず、ランタイムが同梱するCLDRというデータベースを参照します。原理を押さえれば、どの出力が環境依存でどこを固定すべきかを推測でなく仕組みから判断できます。前提として基礎は JavaScript を押さえておくと、ここでの内部動作がつながります。
ロケールタグはBCP47で正規化される
Intlに渡すロケール文字列はBCP47言語タグとして解釈されます。タグはハイフン区切りのサブタグ列で、おおむね次の構造です。
| サブタグ | 例 | 意味 |
|---|---|---|
| 言語 (必須) | ja, en, zh | ISO 639の言語コード |
| スクリプト | Hans, Hant, Latn | 表記体系(簡体/繁体など) |
| 地域 | JP, US, DE | ISO 3166の国・地域コード |
| 拡張 (u) | -u-nu-latn, -u-ca-japanese | Unicode拡張:数字体系・暦など |
各APIはまず受け取ったタグを正規化します。大文字小文字を正規形(言語は小文字、スクリプトは先頭大文字、地域は大文字)に直し、u拡張のキーをアルファベット順に並べ替え、zh-yue のような非推奨タグを正規の yue へ写像します。zh-Hans-CN-u-nu-hanidec のようにスクリプト・地域・拡張を一括指定でき、-u- 以降のUnicode拡張で数字体系(nu)・暦(ca)・照合(co)などをロケール単位で要求できます。
ロケール解決:要求と利用可能の突き合わせ
正規化したタグが、そのランタイムで実際に使えるかは別問題です。各コンストラクタは要求リストとランタイムの利用可能ロケール集合(CLDRが同梱する範囲)を突き合わせ、使うロケールを1つ決めます。この手続きが ResolveLocale で、既定のマッチングは**lookup(言語ネゴシエーション)**です。
要求: "de-AT-u-co-phonebk"
1. 完全一致を探す → 無ければ
2. 末尾サブタグを削って再試行: "de-AT" → "de"
3. ヒットした最長の接頭辞を採用(ここでは "de")
4. u拡張(co=phonebk)はサポートされていれば引き継ぐ
末尾から段階的にサブタグを削って最長一致を探すため、de-AT 未収録でも de にフォールバックします。複数候補を配列で渡すと先頭から順に評価し、最初に解決できたものを採用します。最終的に何が選ばれたかは resolvedOptions().locale で確認できます。
new Intl.DateTimeFormat("de-AT").resolvedOptions().locale が "de" を返すことがあるように、解決後ロケールは要求と食い違い得ます。「de-AT を要求したのだから de-AT で動く」と決め打ちすると、地域固有の暦や週の開始曜日が期待とずれます。実際に効くロケール・暦・数字体系は必ず resolvedOptions() で読み取って確認してください。
出力規則の出所はCLDR
桁区切り文字、小数点記号、通貨記号の位置、月名、並べ替え順——これらの規則そのものはV8やJavaScriptCoreの実装ではなく、Unicodeコンソーシアムが整備する CLDR(Common Locale Data Repository) に由来します。Intlは「規則を解釈するエンジン」、CLDRは「規則データ」という分担です。
| API | CLDRから引くデータの例 |
|---|---|
| NumberFormat | 小数点・桁区切り記号、桁グループ幅、通貨記号と配置、数字体系 |
| DateTimeFormat | 月・曜日名、日付/時刻パターン、暦、週の開始曜日、時間帯名 |
| Collator | 照合要素表(DUCET)とロケール別の差分(tailoring) |
| Segmenter | 書記素・単語・文の境界規則(Unicode Text Segmentation) |
この分担の重要な帰結は、同じコードでも実行環境で出力が変わり得ることです。ランタイムが同梱するCLDRのバージョンが上がると、新しい通貨記号や改訂された並べ替え規則が反映されます。
assert(format(1234.5) === "1,234.5") のようなテストは、ICU/CLDRのバージョン差で容易に壊れます。スペースに見えるグループ区切りが実は**NBSP(U+00A0)やナローNBSP(U+202F)**であることも多く、目視一致でもバイト不一致になります。テストでは出力文字列の固定比較を避け、resolvedOptions() の値検証や formatToParts() の構造(型タグの並び)を検証する形にしてください。
Collator:文字列を重み列へ写像して比較
Intl.Collator は文字列を文字コード順ではなく言語的に正しい順で比較します。原理はUnicode照合アルゴリズム(UCA)で、各文字を**多段の照合要素(重み)**へ写像し、段ごとに比較します。
- 第1段(primary):基底文字の違い(
aとb)。最も強い差。 - 第2段(secondary):アクセント差(
aとá)。 - 第3段(tertiary):大文字小文字差(
aとA)。
まず全文字列を第1段の重みだけで比較し、同点なら第2段、さらに同点なら第3段へ進みます。これにより「アクセントや大小は無視して並べたい」を sensitivity で制御できます。基準表はDUCET(既定の照合要素表)で、各ロケールはそこへ**tailoring(差分)**を当てます。たとえばドイツ語電話帳順(-u-co-phonebk)はウムラウトを特別扱いします。
const c = new Intl.Collator("de", { sensitivity: "base" });
c.compare("ä", "a"); // 0 (base感度:アクセント差を無視=同等)
["z", "ä", "a"].sort(new Intl.Collator("de").compare); // 言語規則で並ぶ
"ä" < "a"; // false …だがコード順比較は言語的に誤り
素朴な < はコード単位の数値比較(テキストエンコーディングの内部(UTF-16のサロゲートとコードポイント) 参照)で、ä(U+00E4)が a(U+0061)より大きいと判定するなど言語的に破綻します。ユーザー向けの並べ替えは必ず Collator を使います。
Segmenter:境界をUnicode規則で決める
Intl.Segmenter は文字列を書記素・単語・文の単位に分割します。境界の位置はUnicodeのテキスト分割規則(UAX #29 など)に従い、ロケールによって異なります。
const seg = new Intl.Segmenter("ja", { granularity: "word" });
[...seg.segment("東京都に住む")].map(s => s.segment);
// 日本語は空白で区切られないため、規則・辞書ベースで語に分割される
書記素分割は結合文字や絵文字のZWJ連結を1単位にまとめ、単語分割はタイ語・日本語のように空白で区切らない言語で辞書を要する場合があります。segment() が返す各セグメントには isWordLike などの付随情報が付き、単語と区切り記号を区別できます。「見た目の1文字数」や「カーソル移動の単位」はここが基準です。
表示の折りたたみ:formatToPartsで構造を取る
format() は最終的な1本の文字列を返しますが、その内部では部品(パート)の列が組み立てられ、最後に連結(折りたたみ)されます。formatToParts() はこの折りたたみ前の構造を露出します。
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
.formatToParts(1234.5);
// [{type:"integer",value:"1"},{type:"group",value:"."},
// {type:"integer",value:"234"},{type:"decimal",value:","},
// {type:"fraction",value:"50"}, // 通貨は小数2桁に丸めるため "5" でなく "50"
// {type:"literal",value:" "}, // この空白は実は NBSP(U+00A0)
// {type:"currency",value:"€"}]
type で各部品の役割(integer/group/decimal/currency など)が分かるため、通貨記号だけ色を変える、グループ区切りの実体を検査するといった処理が、文字列を正規表現で割らずに安全に行えます。前述のNBSP問題も、literal パートの中身を見れば確実に判別できます。
「Intlの規則はどこから来るか」の答えはCLDRで、ランタイムのICU/CLDRバージョンで出力が変わる点が問われます。ロケール解決の既定は**lookup(最長接頭辞一致のフォールバック)**で、結果は resolvedOptions().locale に出る。BCP47の -u- 拡張で数字体系・暦・照合をロケール内に指定できる——この3点を押さえてください。
実務での落とし穴と対策
帰結を実務に落とすと、危険なのはIntl出力を環境非依存だと仮定することです。
- インスタンスは使い回す:
NumberFormat/DateTimeFormatの生成はCLDRデータの読み込みを伴い重い。ループ内で毎回newせず、ロケール・オプション単位でキャッシュする。 - グループ区切りの実体に注意:多くのロケールで区切りはNBSP系の不可視空白。CSVや数値パースに渡す前に
formatToParts()で実体を確認するか、機械処理用にはIntlを使わない。 - 暦・数字体系の取りこぼし:
ar-SAは既定でヒジュラ暦・アラビア数字になり得る。固定の表記が必要なら-u-ca-gregory-nu-latnのように明示する。 - 比較・重複判定は正規化と併用:
Collatorの前提として、見た目が同じ文字列はNFCで正規化しておく(数値の表現差については JavaScriptの数値表現とIEEE754 も同じ「内部表現の帰結」という観点でつながります)。
まとめ
Intl(ECMA-402)は規則を自前で持たず、ランタイムが同梱するCLDRデータを参照する解釈エンジンです。渡したロケールはBCP47タグとして正規化され、ResolveLocale がランタイムの利用可能集合と突き合わせて使用ロケールを決めます。既定の解決はlookup=末尾サブタグを削る最長接頭辞一致のフォールバックで、結果は resolvedOptions().locale に表れます。NumberFormat/DateTimeFormat の記号・名称・パターンも、Collator の照合要素(DUCET+tailoring)も、Segmenter の境界規則もすべてCLDR由来のため、同じコードでもICU/CLDRバージョンで出力が変わり得ます。だからこそ出力文字列のハードコード比較は避け、resolvedOptions() と formatToParts() の構造で検証するのが定石です。「規則はコードでなくデータにある」を起点にすれば、国際化の表示バグは推測でなく仕組みから防げます。
Web/フロントエンド Article
Intlによる国際化フォーマットの内部(CLDRとロケール解決)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
数値・日付・並べ替え・分割の規則は実装ではなくCLDR(Unicode共通ロケールデータ)に由来する。同じコードでもランタイムやCLDRのバージョン差で出力が変わり得るため、出力をハードコードで比較してはいけない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「JavaScript / Intl」に近いか確認する。
- 強みである「Intlの各APIは渡したロケールをBCP47タグとして正規化し、ランタイムが持つCLDR由来のデータと突き合わせて利用可能なロケールを決める。要求が無ければ言語サブタグを段階的に削る言語ネゴシエーション(lookup)で代替を探す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。