i18n辞書を1ファイルに集約する設計判断
LeapLensのi18n辞書は src/i18n/translations.ts の1ファイル にまとめている。
ロケール毎にファイル分割しない選択は意図的で、運用上の効果が出ている。
モジュール分割しない理由
ありがちな構成:
src/i18n/
├── ja.json
├── en.json
└── index.ts
これだと:
- キー一致のチェックが手動になる(jaに追加してenを忘れる事故)
- JSONは型推論が弱い(
t("foo")の補完が効かない) - 新キー追加時に2ファイル開く必要(認知負荷)
LeapLensでは1ファイル + TypeScriptの type Dict で構造を強制し、ja: Dict と en: Dict の両方が同じ型に従う前提にしている。
キー一致をテストで保証
it("ja と en でキーが一致する", () => {
const jaKeys = Object.keys(t("ja")).sort();
const enKeys = Object.keys(t("en")).sort();
expect(jaKeys).toEqual(enKeys);
});
Dict 型から逸脱すればTSエラーになるが、それでもこのテストを置くことで 構造的な保証 が二重になる。
辞書に新キーを追加して片方だけ書いたらCIがブロックする。
関数も辞書に入れる
固定文字列だけでなく、関数型のエントリも置ける:
type Dict = {
// ...
footerCopyright: (year: number, name: string) => string;
};
jaとenで書式は同じだが、複数形変化のような言語固有の処理が必要になった時もここで吸収できる(現状は使っていないが将来用)。
localeヘルパーの整理
辞書とは別に、URLベースでlocaleを判定するヘルパーを4つ用意した:
| 関数 | 用途 |
|---|---|
getLocaleFromPath(pathname) |
/en/... なら "en"、それ以外は "ja" |
stripLocaleFromPath(pathname) |
/en/contact/ → /contact/ |
localizedPath(path, locale) |
pathにlocale prefixを付与 |
withLocalePrefix(path, locale) |
strip + 再付与の組み合わせ(ロケール切替リンク用) |
これらをすべて1ファイルにまとめ、コンポーネントはimport 1行で全機能 が使える。
まとめ
i18nは規模が大きくなりがちだが、最初から「辞書 + 型 + テスト + ヘルパー」を1ファイルに同居させると、認知負荷が低くキー追加のフリクションも小さい。 規模が拡大して1ファイルが重くなったらモジュール分割すれば良いが、辞書の鮮度を保つ観点で、現状は単一ファイルが正解だった。