言語を増やすたびに未翻訳が本番へ漏れる
個人開発で運用しているアプリのひとつに、ある週末に2言語を足したことがあります。翻訳ファイルを2つ追加し、画面をひととおり眺め、問題なさそうに見えたのでリリースしました。数日後、ある画面の見出しだけが英語のまま表示されているという報告が届きました。原因は単純で、その言語ファイルを書いた時点には存在しなかったキーを、別の機能追加であとから足していたのです。新しいキーは既定言語にしか入っておらず、追加した2言語には反映されていませんでした。
このとき気づいたのは、未翻訳が漏れるのは「言語を足したとき」ではなく「キーを足したとき」だということです。多言語アプリの破綻は、たいてい翻訳作業そのものではなく、キーの増減と翻訳の充足が静かにズレていく運用のほうから来ます。そのズレを目視ではなく仕組みで止めるための設計を、実装込みで共有します。
なぜ未翻訳は「あとから」漏れるのか
翻訳ファイルを最初に用意したときは、全言語が同じキー集合を持っています。ところがアプリは育ちます。機能を足せば文言が増え、その文言は普通、自分が書ける言語(多くは日本語か英語)にしか入りません。残りの言語へ反映するのは別の工程で、ここに時間差が生まれます。
問題は、多くの i18n ライブラリが「キーが無い」ときに沈黙することです。キーを引けなければキー名そのものを表示したり、既定言語にこっそり落としたりします。クラッシュしないのは利点ですが、未翻訳がエラーにならないため、本番に出るまで誰も気づきません。目視レビューは画面数と言語数の掛け算で破綻します。3言語で20画面なら60通り、ここに動的な状態が乗るので、人間が全部見るのは現実的ではありません。
ですから設計の出発点はひとつです。翻訳の充足は人間の注意力ではなく、機械的な差分検出で担保する。 以下、その土台から順に組み立てます。
文言カタログを単一の真実に置く
最初にやるべきは、文言を「型のあるカタログ」として一元化することです。既定言語(私の場合は日本語)を真実の源とし、ここに存在するキーの集合を全言語が満たすべき契約として扱います。
// i18n/catalog.ts — 既定言語(ja)がキーの真実の源
export const ja = {
common: {
save: "保存" ,
cancel: "キャンセル" ,
},
paywall: {
title: "すべての機能を解放" ,
cta: "続ける" ,
restore: "購入を復元" ,
},
} as const ;
// 既定言語からキーの型を導出する。他言語はこの型を満たさないと型エラーになる
export type Catalog = typeof ja;
// 他言語は Catalog 型で受ける(深いキーの抜けをコンパイル時に検出)
import type { DeepPartial } from "./types" ;
export const en : Catalog = {
common: { save: "Save" , cancel: "Cancel" },
paywall: { title: "Unlock everything" , cta: "Continue" , restore: "Restore purchases" },
};
ここで en を Catalog 型(DeepPartial ではない)で受けるのが要点です。en にキーが足りなければ TypeScript がコンパイル時にエラーを出します。つまり「既定言語に足したキーを英語に入れ忘れる」事故は、CI を待たずエディタ上で赤くなります。
ただし型で守れるのは、ファイルとして存在しキー構造を型で受けている言語だけです。翻訳者から JSON で受け取る言語や、機械翻訳でいったん流し込む言語は型の外に出るため、次の機械検出が必要になります。
キーの抜けと余りを CI で止める
全ロケールのキー集合を既定言語と突き合わせ、抜け(missing)と余り(extra)を検出するスクリプトを CI に置きます。抜けは未翻訳、余りは「削除した文言の翻訳が残っている死蔵キー」で、どちらも放置すると蓄積します。
// scripts/i18n-check.mjs — 全ロケールのキー差分を検出して exit code で止める
import { ja } from "../i18n/catalog.js" ;
import * as locales from "../i18n/locales/index.js" ;
const flatten = ( obj , prefix = "" ) =>
Object. entries (obj). flatMap (([ k , v ]) => {
const key = prefix ? `${ prefix }.${ k }` : k;
return typeof v === "object" && v !== null ? flatten (v, key) : [key];
});
const base = new Set ( flatten (ja));
let failed = false ;
for ( const [ locale , dict ] of Object. entries (locales)) {
const keys = new Set ( flatten (dict));
const missing = [ ... base]. filter (( k ) => ! keys. has (k));
const extra = [ ... keys]. filter (( k ) => ! base. has (k));
const coverage = (((base.size - missing. length ) / base.size) * 100 ). toFixed ( 1 );
console. log ( `[${ locale }] 充足率 ${ coverage }% 欠落 ${ missing . length } 余剰 ${ extra . length }` );
if (missing. length ) {
console. error ( ` ✗ 未翻訳: ${ missing . slice ( 0 , 10 ). join ( ", " ) }${ missing . length > 10 ? " …" : ""}` );
failed = true ;
}
if (extra. length ) {
console. warn ( ` △ 死蔵キー: ${ extra . slice ( 0 , 10 ). join ( ", " ) }` );
}
}
process. exit (failed ? 1 : 0 );
これを package.json の lint や push 前フックから呼べば、未翻訳のあるロケールがひとつでもあればビルドが赤くなります。私はこのスクリプトの出力(充足率の行)をそのまま PR コメントに貼る運用にしていて、「英語 100%、ドイツ語 92%、未翻訳12件」のように一目で分かるようにしています。死蔵キーは即エラーにせず警告に留めるのがコツで、文言の削除直後は一時的に残るのが普通だからです。
検出をどの段で行うかで、漏れたときの被害が変わります。下表は私が経験した「気づくのが遅れるほど高くつく」順です。
検出する段 気づくまで 修正コスト 本番露出
エディタ(型エラー) 書いた瞬間 最小 なし
CI(キー差分検出) push 時 小 なし
手動 QA リリース前(運次第) 中 なし〜小
ユーザー報告 本番後 大(再ビルド/再審査) あり
型と CI の2段で止めれば、手動 QA は「翻訳の質」を見る作業に集中でき、「翻訳の有無」を人手で確認する不毛なチェックから解放されます。
フォールバック連鎖を意図して設計する
それでも未翻訳がすり抜ける瞬間はあります。機械翻訳の流し込みが間に合わなかった言語、リリース直前に足した文言などです。このとき何を見せるか を、ライブラリ任せにせず明示的に決めておきます。
最悪なのはキー名(paywall.title のような文字列)がそのまま表示されることです。これは確実に避け、フォールバック連鎖を「その言語 → 英語 → 既定言語(日本語)」のように定義します。
// i18n/index.ts — 明示的なフォールバック連鎖
import i18n from "i18next" ;
import { initReactI18next } from "react-i18next" ;
import * as Localization from "expo-localization" ;
i18n. use (initReactI18next). init ({
resources: { ja: { translation: ja }, en: { translation: en }, de: { translation: de } },
lng: Localization. getLocales ()[ 0 ]?.languageCode ?? "en" ,
fallbackLng: [ "en" , "ja" ], // 連鎖: 当該言語 → 英語 → 日本語
returnEmptyString: false , // 空文字の翻訳でフォールバックを止めない
parseMissingKeyHandler : ( key ) => {
if (__DEV__) console. warn ( `[i18n] missing key: ${ key }` );
return "" ; // キー名は絶対に表示しない。空にして連鎖へ委ねる
},
});
parseMissingKeyHandler でキー名の表示を封じ、開発時のみ警告を出すのが要点です。fallbackLng を配列にして連鎖を明示しておくと、たとえばドイツ語が欠けたときに英語が出て、英語も無ければ日本語が出ます。ユーザーから見れば「自分の言語ではないが意味は通じる」状態に着地し、キー名の生表示という最悪は起きません。
フォールバックは安全網であって、未翻訳を許す言い訳にしてはいけません。CI で止めるのが本筋で、フォールバックは「それでもすり抜けた1件をユーザーに見苦しく見せない」ための最後の一枚です。
複数形と語形をキーに溶かさない
未翻訳と並んで崩れやすいのが複数形です。「3 件」「3 items / 1 item」のように、言語ごとに数による語形の規則が違います。英語は単複の2形ですが、ロシア語やアラビア語はもっと多くの形を持ちます。これを自前の三項演算子で書くと、英語の感覚で書いた分岐が他言語で破綻します。
ICU の複数形カテゴリ(one / other など)に従い、ライブラリに語形選択を委ねます。
// catalog 側: ICU の複数形を素直に表現
en : {
cart : { items_one : "{{count}} item" , items_other : "{{count}} items" },
},
ja : {
cart : { items_other : "{{count}} 件" }, // 日本語は単複の区別がないので other だけ
},
// 呼び出し側
t ( "cart.items" , { count: n }); // n に応じてライブラリが one/other を選ぶ
日本語は数で語形が変わらないため other だけで足りますが、英語は one と other を持たせます。i18n-check スクリプトは _one / _other のサフィックス込みでキーを比較するので、英語に _one を入れ忘れれば未翻訳として検出されます。複数形を「キーの分岐」としてカタログに置くことで、翻訳の充足検査の網に複数形も乗ります。
レイアウト崩れは擬似ロケールで先に見つける
翻訳は意味だけでなく長さの問題でもあります。ドイツ語は英語より2〜3割長くなることが多く、ボタンの文字がはみ出したり、2行に折り返して下の要素を押し下げたりします。本物の翻訳が揃う前にこれを見つけるために、擬似ロケール(pseudolocalization)を使います。
// i18n/pseudo.ts — 既定言語を変換して長さと文字種を擬似的に再現
export const pseudoize = ( s : string ) : string => {
const map : Record < string , string > = { a: "á" , e: "é" , i: "í" , o: "ó" , u: "ú" };
const expanded = s. replace ( / [aeiou] / g , ( c ) => map[c] ?? c);
// 30% 長くして最長言語の幅を模す。両端の囲みで切れを検出
const pad = "~" . repeat (Math. ceil (expanded. length * 0.3 ));
return `⟦${ expanded } ${ pad }⟧` ;
};
開発ビルドで言語を擬似ロケールに切り替えると、すべての文言が3割長く、アクセント付きで、両端を ⟦ ⟧ で囲まれて表示されます。文字が切れていれば囲みの片方が見えず、はみ出していればすぐ分かります。アクセント文字はフォントのグリフ欠けも炙り出します。私はこれを実機の開発ビルドに隠しトグルとして仕込み、新画面を作るたびに一度切り替えて眺めるようにしています。翻訳者に渡す前に「どの言語が来ても収まる」ことを確認できるのが利点です。
言語追加を定型作業に落とす
ここまでの部品が揃うと、言語の追加が「祈りながらリリース」ではなく定型作業になります。私が新言語を足すときに踏んでいる手順は次のとおりです。
既定言語(ja)のカタログを i18n-check の基準として確定する(死蔵キーをこの時点で掃除する)
新ロケールのファイルを作り、機械翻訳で全キーをいったん埋める(空のキーを残さない)
node scripts/i18n-check.mjs を実行し、充足率 100%・欠落 0 を確認する
擬似ロケールではなく実ロケールで主要画面を一巡し、はみ出しと不自然な改行だけを見る
複数形を持つ画面(カート・リスト件数など)で 0/1/2/多 を実際に表示して語形を確認する
CI を通してマージし、OTA かストア配信かをリリース規模で切り分ける
機械翻訳で全キーを埋めるのは品質のためではなく、未翻訳キーをゼロにして検査を通すため です。質は後から人手で上げればよく、まず「抜けが無い状態」を作ってしまうのが安全です。
充足率を計測値として残しておくと、運用の健康度が見えます。私はリリースごとに各言語の充足率をログに残し、特定言語が下がり続けていないかを月次で眺めています。下がっていれば、その言語向けの文言追加が翻訳工程に追いついていない兆候です。
おわりに
多言語対応で本当に難しいのは翻訳そのものではなく、キーが増減し続けるアプリで充足を保ち続けることです。型で書いた瞬間に止め、CI でキー差分を止め、フォールバックで最後の1件を見苦しくなく着地させ、擬似ロケールで長さを先に見つける。この4枚を重ねると、未翻訳は「ユーザーが見つけるもの」から「機械が止めるもの」に変わります。
まずは scripts/i18n-check.mjs を1本置き、push 前に走らせるところから始めてみてください。充足率が数字で見えるだけで、次にどの言語へ手を入れるべきかが静かに分かるようになります。私自身、この仕組みに何度も助けられてきました。同じように多言語アプリを育てている方の助けになれば幸いです。