Unicode CLDR とは — Intl や ICU が読んでいるロケールデータ
CLDR(Unicode Common Locale Data Repository)は、ロケールごとに日付・数値・通貨・複数形・列挙・相対時間・名称・並び順をどう書くかを記録した、バージョン管理されたデータセットです。コードではなくデータで、それを読むライブラリが ICU(International Components for Unicode)です。JavaScript の Intl も、Android や Apple や .NET の書式化 API も、その上に乗った窓口です。日付を書式化する行為は、CLDR への問い合わせです。
この記事の出力は1台のマシンで実行したもので、版そのものが根拠の一部です。Node 24.18.1 が同梱する ICU 78.3、CLDR 48.0、Unicode 17.0、タイムゾーンデータベース 2026b の結果です。別のビルドは別の版を積んでいるので、同じ呼び出しが違う文字列を返しえます。以下は「CLDR がそう決めている」例であって、テストで固定してよい定数ではありません。
node -e 'console.log(process.versions.icu, process.versions.cldr, process.versions.unicode)' // 78.3 48.0 17.0
日付と暦 — 並び順、区切り文字、そして見えない制御文字
CLDR はロケールごと・スタイルごとに日付のパターンを持つので、一度の呼び出しで、実装した覚えのないロケールでも正しくなります。注意すべき点が3つあります。ar-EG の短い日付は、見た目9文字に対して UTF-16 で11コード単位あります。パターンがスラッシュの直前にそれぞれ U+200F(RIGHT-TO-LEFT MARK)を挿入しているためで、文字数を測って省略する処理やリテラル比較は、この分だけ狂います。数字の体系もロケールデータで、ar-EG は arab に解決されます。暦は言語とは別の軸で、ja-JP-u-ca-japanese を指定すれば同じ瞬間が和暦で出ます。
地域差も見た目の問題ではありません。de-DE の Januar は de-AT では Jänner で、手書きの月名リストにない綴りです。en-US は h12 で週の始まりを日曜と報告し、en-GB は h23 で月曜、ja-JP は h23 で日曜です(取り方も版で変わり、22.13.0 は weekInfo ゲッターのみ、24.18.1 は getWeekInfo も持ちます)。カレンダー UI の週頭を固定すると必ずどこかでずれます。
const d = new Date(Date.UTC(2026, 7, 15, 13, 5));
const opt = { timeZone: "UTC" };
new Intl.DateTimeFormat("ja-JP", opt).format(d); // 2026/8/15
new Intl.DateTimeFormat("en-US", opt).format(d); // 8/15/2026
new Intl.DateTimeFormat("en-GB", opt).format(d); // 15/08/2026
new Intl.DateTimeFormat("de-DE", opt).format(d); // 15.8.2026
// dateStyle: "long"
// ja-JP 2026年8月15日 en-US August 15, 2026
// de-DE 15. August 2026 ar-EG ١٥ أغسطس ٢٠٢٦
// timeStyle: "short" ja-JP 13:05 / en-GB 13:05 / en-US 1:05 PM
// ar-EG の短い日付には不可視の双方向制御文字が2個入る
const ar = new Intl.DateTimeFormat("ar-EG", opt).format(d);
ar.length; // 11 コード単位(見た目は9文字)
[...ar].map((c) => c.codePointAt(0).toString(16));
// 661 665 200f 2f 668 200f 2f 662 660 662 666
// U+200F はスラッシュの直前に1個ずつ、index 2 と 5 にある
new Intl.DateTimeFormat("ja-JP-u-ca-japanese", { dateStyle: "long", ...opt }).format(d);
// 令和8年8月15日
new Intl.Locale("ja-JP").getWeekInfo(); // { firstDay: 7, weekend: [6, 7] }
new Intl.Locale("en-GB").getWeekInfo(); // { firstDay: 1, weekend: [6, 7] }数値と通貨 — 3種類の空白と、通貨ごとの丸め規則
桁区切りと小数点は分岐が思うより多いです。de-CH はアポストロフィで桁を区切って小数点はピリオドなので、de-DE と両方で食い違います。版が根拠の一部だという一番はっきりした証拠もここにあります。de-CH の桁区切りは Node 22.13.0(ICU 76.1 / CLDR 46.0)では U+2019、ICU 78.3(CLDR 48.0)では U+0027 です。hi-IN は上位を3桁ではなく2桁ずつ区切ります。ar-EG はカンマもピリオドも使わず、U+066C と U+066B という専用の文字を使います。
見分けられない空白もあります。同じ1つのビルドの中で、fr-FR は U+202F(NARROW NO-BREAK SPACE)、de-AT は U+00A0(NO-BREAK SPACE)で桁を区切ります。どちらも画面では隙間ですが、U+0020 ではありません。de-DE のユーロ記号の手前も U+00A0 です。書式化した数値を自分で組み立てた文字列と比較する処理や、空白で split して解析し直す処理は、テストしていないロケールで落ちます。
JPY は小数桁数が0なので 1234.5 は丸められ、ja-JP の記号は U+00A5 ではなく全角の U+FFE5 です。記号の位置もデータで、パーセントは tr-TR が前置、en-US が後置です。桁ごとに見た目を変えたいなら formatToParts で部品として受け取れます。
const n = 1234567.89;
new Intl.NumberFormat("ja-JP").format(n); // 1,234,567.89
new Intl.NumberFormat("de-DE").format(n); // 1.234.567,89
new Intl.NumberFormat("de-CH").format(n); // 1'234'567.89
new Intl.NumberFormat("hi-IN").format(n); // 12,34,567.89
// 同じビルドの中で、見た目が同じ空白の正体が違う
[...new Intl.NumberFormat("fr-FR").format(n)].map((c) => c.codePointAt(0).toString(16));
// 31 202f 32 33 34 202f 35 36 37 2c 38 39 fr-FR は U+202F
[...new Intl.NumberFormat("de-AT").format(n)].map((c) => c.codePointAt(0).toString(16));
// 31 a0 32 33 34 a0 35 36 37 2c 38 39 de-AT は U+00A0
const cur = (loc, currency) =>
new Intl.NumberFormat(loc, { style: "currency", currency }).format(1234.5);
cur("ja-JP", "JPY"); // ¥1,235 U+FFE5、JPY は小数0桁なので丸まる
cur("en-US", "USD"); // $1,234.50
cur("de-DE", "EUR"); // 1.234,50 € 記号の手前は U+00A0
cur("en-US", "KWD"); // KWD 1,234.500
new Intl.NumberFormat("tr-TR", { style: "percent" }).format(0.42); // %42
new Intl.NumberFormat("en-US", { style: "percent" }).format(0.42); // 42%複数形カテゴリ — 単数形と複数形の2本では足りない
CLDR が言語ごとに割り当てる複数形カテゴリの集合は、2個とは限りません。日本語は other の1個だけなので、数に応じた分岐を必要としません。英語は2個、ロシア語は4個、アラビア語は zero を含む6個です。配列の順序も版で変わるので、集合として扱います。
選択は数値に対する規則で、単純な個数判定ではありません。ロシア語では 21 と 101 が one を選び、0・5・11・100 は many を選びます。ポーランド語はカテゴリの集合がロシア語と同じ4個なのに、21 では many を選びます。単数形と複数形を1つずつ持つ UI は、どちらの言語でも埋めようがありません。
英語圏のチームが引っかかるのはフランス語です。select(0) が one を返すので、ゼロが単数です。1.5 も one です。さらに切りのよい大きな数だけで発火する many があり、1000000 は many、1100000 は other になります。序数は別の規則で、英語では 21 が one、11 が other です。
数に応じた分岐は、コードの if 文ではなく翻訳文字列の内側に置きます。ICU MessageFormat の解説にある plural 引数がその仕組みで、選択の根拠が CLDR の複数形ルールです。
const cats = (loc) => new Intl.PluralRules(loc).resolvedOptions().pluralCategories;
cats("ja"); // [ "other" ]
cats("en"); // [ "one", "other" ]
cats("ru"); // [ "one", "few", "many", "other" ]
cats("ar"); // [ "zero", "one", "two", "few", "many", "other" ]
// Intl.PluralRules(loc).select(n)
// n: 0 1 2 3 11 21 101 1.5
// ja other other other other other other other other
// en other one other other other other other other
// ru many one few few many one one other
// pl many one few few many many many other
// ar zero one two few many many other other
// fr one one other other other other other one
new Intl.PluralRules("fr").select(1000000); // many
new Intl.PluralRules("fr").select(1100000); // other
const ord = new Intl.PluralRules("en", { type: "ordinal" });
[1, 2, 3, 4, 11, 21].map((n) => ord.select(n));
// [ "one", "two", "few", "other", "other", "one" ]名称・列挙・相対時間・並び順もデータで持っている
このビルドで英語の地域名 TR は Türkiye で、CLDR のリリースノートにこの改称が 42 で入った記録があります。手で管理する国名リストは改称の時点で黙って古くなります。言語名には地域の限定も付き、ブラジルポルトガル語の注意点や中南米スペイン語とスペイン本国の違いは、意見ではなくデータとして入っています。
列挙の結合もパターンで、カンマではありません。en-US は接続詞の前にカンマを入れ、en-GB は入れません。日本語は読点で並べ、接続詞を入れません。配列を join して最後に and を足すドロップ表示は、ほとんどのロケールで誤りです。相対時間も同じくパターンです。
既定の並べ替えはコードポイント比較なので、アクセント付きの語が全部 z の後ろに行きます。ドイツ語では ä が a と一緒に並び、スウェーデン語では ä と ö が独立した字として z の後に来ます。スペイン語では ñ が n と o の間です。Collator の compare を sort に渡すだけで直り、numeric を付ければ Item1・Item10・Item2 の並びも直ります。
const reg = (loc) => new Intl.DisplayNames([loc], { type: "region" }).of("TR");
reg("en"); // Türkiye
reg("ja"); // トルコ
new Intl.DisplayNames(["ja"], { type: "language" }).of("pt-BR"); // ポルトガル語 (ブラジル)
new Intl.DisplayNames(["ja"], { type: "language" }).of("zh-Hant"); // 繁体中国語
new Intl.DisplayNames(["ja"], { type: "currency" }).of("JPY"); // 日本円
const and = (loc, list) => new Intl.ListFormat(loc, { type: "conjunction" }).format(list);
and("en-US", ["Sword", "Shield", "Potion"]); // Sword, Shield, and Potion
and("en-GB", ["Sword", "Shield", "Potion"]); // Sword, Shield and Potion
and("de", ["Sword", "Shield", "Potion"]); // Sword, Shield und Potion
and("ja", ["剣", "盾", "薬草"]); // 剣、盾、薬草
new Intl.ListFormat("ja", { type: "disjunction" }).format(["剣", "盾", "薬草"]);
// 剣、盾、または薬草
const rtf = (loc) => new Intl.RelativeTimeFormat(loc, { numeric: "auto" });
rtf("ja").format(-1, "day"); // 昨日
rtf("ja").format(-3, "day"); // 3 日前
rtf("ja").format(2, "week"); // 2 週間後
rtf("de").format(-3, "day"); // vor 3 Tagen
const words = ["zebra", "ärger", "apfel", "Öl", "osten"];
[...words].sort(); // apfel osten zebra Öl ärger
[...words].sort(new Intl.Collator("de").compare); // apfel ärger Öl osten zebra
[...words].sort(new Intl.Collator("sv").compare); // apfel osten zebra ärger Öl
["Item10", "Item2", "Item1"].sort(new Intl.Collator("ja", { numeric: true }).compare);
// Item1 Item2 Item10CLDR はデータ、ICU はライブラリ、版はプラットフォームごとに固定される
CLDR は LDML(Locale Data Markup Language)という XML 形式(JSON 配布版もあり)で、おおむね年2回公開されます。ICU がそれをバイナリにコンパイルし、API として見せます。下流はどこかの ICU ビルドを固定し、更新するまでそのままです。
ブラウザはそれぞれ自分の ICU を同梱するので、同じ JavaScript が別のブラウザで違う書式になります。Node の small-icu ビルドは英語データしか持ちません。Android は API レベル 24 以降 android.icu として ICU4J を公開しており、Android の strings.xmlの裏側にあるフォーマッタも同じデータに乗ります。Apple は Foundation のロケール API 経由です。.NET は .NET 5 以降すべてのプラットフォームで既定が ICU で、Windows のアプリはランタイム構成スイッチで従来の NLS API に戻すこともできます(.NET の globalization と ICU のドキュメントに記載)。
ゲーム開発では二重に効きます。Unity や Godot の C# 側はそのプラットフォームの .NET が積む ICU を使い、Unreal はエンジンに ICU のビルドを同梱します(国際化のドキュメントに記載)。1つのゲームが同じ数値を2つのプラットフォームで違う書式で表示しうるのは、バグではなく版の違いです。バイト単位の一致が必要なら、片方で書式化して文字列のまま送ります。
ロケールタグも文字列比較せず、Intl.getCanonicalLocales で正規化します。未知のタグは黙ってランタイムの既定ロケールに落ちます。英語とは限りません。zz-ZZ はこの機械では en-US ですが、LANG が ja-JP なら ja-JP に解決されます。ロケール一覧のタイプミスは、エラーではなく動いているロケールとして現れます。何に解決されたかは resolvedOptions().locale で確認でき、タグの構造はBCP 47 の言語タグで扱っています。
new Intl.DateTimeFormat("de-AT").resolvedOptions().locale; // de-AT(データがある)
new Intl.DateTimeFormat("zz-ZZ").resolvedOptions().locale;
// この機械では en-US。既定ロケール依存で、LANG=ja-JP なら ja-JP
Intl.getCanonicalLocales(["JA-jp", "zh-hans-cn", "PT-br"]);
// [ "ja-JP", "zh-Hans-CN", "pt-BR" ]
// 1つのビルドが積んでいるデータ量
Intl.supportedValuesOf("timeZone").length; // 418
Intl.supportedValuesOf("currency").length; // 162
Intl.supportedValuesOf("numberingSystem").length; // 78
Intl.supportedValuesOf("calendar").length; // 18どこまで任せ、どこから自前で書くか
読者のロケールの慣習であるものは全部データに任せます。時計制、週の始まり、複数形の選択、列挙の結合、並び順まで含めてです。
自前で書くのは、その文字列がゲームの世界観の一部で、読者のロケールの慣習ではない場合です。あえて桁区切りを入れないステータス数値、実在しない通貨、架空の月名を持つゲーム内カレンダー。その場合でも、useGrouping を false にした NumberFormat のほうがコード経路が1本のままで安全です。
線引きは「そのロケールのネイティブが見て、書式が間違っていると言うか」で引けます。言うならロケール側の決定で、CLDR に答えがあります。言わず、根拠が自分たちの見せ方だけなら自前の領域です。どちらの場合も書式は翻訳対象の文字列の外に置き、翻訳者にはパターンを渡します。