壁紙アプリを16言語に対応させて公開した数日後、ロシア語圏の方から「お気に入りの件数の表示がどこかおかしい」という短い報告が届きました。手元の iPhone を日本語と英語で確かめても、何も問題は起きません。
原因にたどり着くまで少し時間がかかりました。私は件数の表示を n === 1 ? "1 item" : n + " items" のような書き方で組んでいたのです。英語と日本語だけを見ていると、この分岐は永遠に正しく見えます。けれどロシア語では、2件と5件で語形が変わり、しかも21件はまた1件と同じ形に戻る。三項演算子の前提そのものが、その言語には存在していませんでした。
数量表現が言語ごとにどう分岐するのかを、CLDR の複数形カテゴリという地図で捉え直します。そのうえで Intl.PluralRules と i18next で正しい形を選ぶ実装を、手を動かしながら整理していきます。個人開発で作ったアプリを世界に出すとき、静かに読者を戸惑わせるこの落とし穴を、公開前に塞いでおきたいのです。
「1なら単数、それ以外は複数」が成り立たない言語
私たちが無意識に持っている「単数か複数か」という二分法は、英語という一つの言語の都合に過ぎません。世界の言語を見渡すと、数量の語形はもっと多様です。
日本語には文法上の複数形がありません。「1件」も「3件」も語形は変わらず、n を差し込むだけで済みます。英語は1のときだけ特別で、あとは同じ。ここまでなら三項演算子で足ります。
問題はその先です。ロシア語やウクライナ語は、末尾の桁によって三つの語形を使い分けます。ポーランド語も同様に複雑で、アラビア語にいたっては0・1・2・少数・多数・その他という六つの形を持ちます。「それ以外は複数」という一本の分岐では、これらの言語を一つも正しく表示できません。
つまり数量表現のバグは、開発者が読めない言語でだけ静かに起こります。手元の端末では再現しないため、レビューでも気づけません。だからこそ、言語ごとの規則を自分の頭ではなく、標準化された地図に委ねる設計が要ります。
CLDR の複数形カテゴリを地図として持つ
その地図が CLDR(Unicode Common Locale Data Repository)の複数形カテゴリです。CLDR は各言語の数量規則を、次の六つのカテゴリのいずれかへの写像として定義しています。
| カテゴリ | 意味 | この形を使う代表的な言語 |
| one | 単数に相当 | 英語・ドイツ語・スペイン語(1のとき) |
| other | 既定・それ以外 | 日本語・中国語・韓国語(常にこれ一つ) |
| few | 少数 | ロシア語・ポーランド語・チェコ語 |
| many | 多数 | ロシア語・ポーランド語・アラビア語 |
| two | 双数 | アラビア語・スロベニア語・ウェールズ語 |
| zero | ゼロ | アラビア語・ラトビア語 |
大切なのは、これらが数値そのものではなく「その言語における語形の区分」だという点です。ロシア語で21が one に分類されるのは直感に反しますが、規則を暗記する必要はありません。地図の役割は CLDR に任せ、私たちはカテゴリ名に対応する文言だけを用意すればよい、という分担にします。
日本語のように other しか持たない言語では、翻訳者は一つの文言だけを書けば済みます。カテゴリを増やすかどうかは言語側の事情であり、コード側は同じ呼び出しで吸収するのが理想です。
Intl.PluralRules で正しい形を選ぶ
ありがたいことに、この地図を引く処理は標準 API に入っています。Intl.PluralRules は、ロケールと数値を渡すと該当するカテゴリ名を返してくれます。
// 数値からCLDRカテゴリを引く薄いラッパー
// ロケールごとにインスタンスを使い回してコストを抑える
const rulesCache = new Map<string, Intl.PluralRules>();
function pluralCategory(locale: string, n: number): Intl.PluralCategory {
let rules = rulesCache.get(locale);
if (!rules) {
rules = new Intl.PluralRules(locale); // 既定は基数(cardinal)
rulesCache.set(locale, rules);
}
return rules.select(n);
}
// 例
pluralCategory("en", 1); // -> "one"
pluralCategory("en", 5); // -> "other"
pluralCategory("ru", 2); // -> "few"
pluralCategory("ru", 5); // -> "many"
pluralCategory("ru", 21); // -> "one"
pluralCategory("ja", 3); // -> "other"
select() が返すのは常に六つのカテゴリ名のいずれかです。あとは、そのカテゴリに対応する文言を引けば、言語ごとの分岐を自分で書かずに済みます。ここでロシア語の21が one に落ちることに注目してください。三項演算子では決してたどり着けなかった結果を、API が黙って正してくれています。
ただし一つ注意があります。React Native の JavaScript エンジンである Hermes は、Intl を有効にしたビルドでのみ Intl.PluralRules を提供します。無効な構成では実行時に例外になるため、存在を確かめてフォールバックする層を挟んでおくと安全です。
// Intl.PluralRules が無い環境では other に寄せる
// 最低限、多数派の言語で文が壊れないことを保証する
function safeCategory(locale: string, n: number): Intl.PluralCategory {
try {
if (typeof Intl?.PluralRules === "function") {
return pluralCategory(locale, n);
}
} catch {
// 実行時に失敗しても握りつぶし、既定へ倒す
}
return n === 1 ? "one" : "other";
}
フォールバックが英語相当の粗い規則になるのは妥協ですが、Intl を無効にした構成でもアプリが落ちないことを優先します。可能なら Hermes の Intl を有効化するか、@formatjs/intl-pluralrules のポリフィルを読み込んで、本来のカテゴリを取り戻すのが望ましい設計です。
i18next の複数形キーを「溶かさず」に持つ
翻訳ライブラリに i18next を使っているなら、この地図の考え方はキーの命名規約に直結します。i18next は内部で Intl.PluralRules と同じカテゴリを使い、_one _few _many _other といったサフィックスでキーを解決します。
// ja/translation.json — other だけで足りる
{
"favorites_other": "お気に入り {{count}} 件"
}
// ru/translation.json — few / many / other を用意する
{
"favorites_one": "{{count}} избранное",
"favorites_few": "{{count}} избранных",
"favorites_many": "{{count}} избранных",
"favorites_other": "{{count}} избранного"
}
// 呼び出し側は言語を意識しない
t("favorites", { count: favorites.length });
呼び出し側が count を渡すだけで、i18next が現在のロケールに応じて正しいサフィックスのキーを選びます。ここで避けたいのは、favorites_one と favorites_other を自前で count === 1 で選んでキーに埋め込んでしまうことです。それをやると、せっかくライブラリが持っている地図を捨てて、また英語の二分法に戻ってしまいます。数量の分岐はコードではなくデータ側、つまり翻訳ファイルのキーで表現する、と決めておくのが要点です。
未翻訳キーそのものの検出や、フォールバック連鎖の設計は別のテーマなので、そちらは翻訳漏れをCIで止める実装メモにまとめています。本記事はあくまで「正しい語形を選ぶ」層に集中します。
基数と序数は別のルールで選ぶ
もう一つ、見落としやすい分岐があります。「3件」のような個数(基数)と、「3番目」のような順序(序数)は、同じ言語でも別の規則で語形が決まります。英語がその典型で、基数は one/other の二区分ですが、序数は 1st・2nd・3rd・4th と四つの形を持ちます。
Intl.PluralRules はこの違いを type オプションで扱えます。
// 序数用のカテゴリを引く
const ordinal = new Intl.PluralRules("en", { type: "ordinal" });
ordinal.select(1); // -> "one" => 1st
ordinal.select(2); // -> "two" => 2nd
ordinal.select(3); // -> "few" => 3rd
ordinal.select(4); // -> "other" => 4th
const suffix: Record<string, string> = {
one: "st", two: "nd", few: "rd", other: "th",
};
function ordinalEn(n: number): string {
return `${n}${suffix[ordinal.select(n)]}`;
}
ordinalEn(21); // -> "21st"
ランキング画面や「◯番目の作品」といった順位表示で基数のルールを流用すると、英語で「3th」のような表記が出てしまいます。個数と順序は語形の設計が別物だと最初に切り分けておくと、後から順位表示を足したときにも同じ地図を使い回せます。
ネイティブ側でも同じ地図を使う
Rork Max が生成するネイティブ Swift 側、あるいは Xcode の String Catalog を使う場合も、拠り所は同じ CLDR です。String Catalog では文字列に対して Vary by Plural のバリエーションを持たせられ、内部的には従来の .stringsdict と同じカテゴリ(one/few/many/other …)へ写像されます。
言語をまたいで一貫させるうえで大切なのは、JavaScript 側とネイティブ側で別々の分岐ロジックを持たないことです。どちらも「数値からカテゴリを引き、カテゴリに文言を割り当てる」という同じ二段構えに揃えておけば、翻訳の追加や検証も一つの手順で回せます。実装言語が違っても、頭の中の地図を一枚に保つ、という設計判断です。
崩れる言語を先にテストする
この種のバグの厄介さは、開発者が読める言語では決して再現しない点にあります。だからテストは「自分が読めない言語」を狙って置くのが効きます。
まず擬似ロケールで、文字列が展開・折り返しされたときのレイアウト崩れを可視化します。そのうえで、カテゴリを多く持つ言語だけを名指しでスナップショットに入れておきます。
// カテゴリ数が多く壊れやすい言語を代表として固定する
const RISKY = ["ru", "ar", "pl"] as const;
const SAMPLES = [0, 1, 2, 3, 5, 11, 21, 100];
for (const locale of RISKY) {
for (const n of SAMPLES) {
const cat = new Intl.PluralRules(locale).select(n);
// 期待するカテゴリと、翻訳キーの存在を突き合わせる
expect(translationHasKey(`favorites_${cat}`, locale)).toBe(true);
}
}
ここでの狙いは、文言の正しさそのものではなく、「その言語が必要とするカテゴリのキーが欠けていないか」を機械的に確かめることです。ロシア語で many のキーだけ書き忘れる、といった抜けは目視では見つかりません。カテゴリとキーの対応を CI に見張らせておけば、言語追加のたびに同じ安心を得られます。
おわりに
数量表現は、アプリの中では地味な一行です。けれどそこには、その言語を使う人にとっての自然さが宿ります。設計としての要点は、次の三つに絞れます。
- 数値からカテゴリを引く判断は
Intl.PluralRules に委ね、自分で n === 1 の分岐を書かない。
- 語形の分岐はコードではなく、翻訳ファイルのキー側に置く。
- 基数と序数を分けて設計し、カテゴリの多い言語をテストで名指しして守る。
私はこの順序で組み直してから、多言語の数量表示に不安を感じなくなりました。個人的にも、自分が読めない言語ほど判断を機械に委ねるほうが、結果として読者に誠実だと考えています。
私自身、最初の壁紙アプリではこの落とし穴に気づかず、公開後の報告で学びました。同じ遠回りを誰かが省けるなら幸いです。お読みいただきありがとうございました。