Reactで作っているWebアプリケーションに、多言語対応を入れることになった。

着手する前は、たいした作業ではないと高を括っていた。コンポーネントに直接書かれている日本語を、

showToast({
  type: 'success',
  message: 'プロジェクトを作成しました',
});
こんなふうに t() へ置き換えていけば終わる、と。
showToast({
  type: 'success',
  message: t('project.createSuccess'),
});
ところが、実際に手を動かしてみると話は違った。置き換えるだけでは済まない問題が、いくつも顔を出したのだ。まず、t() の引数が string である限り、翻訳キーのtypoは誰も止めてくれない。
t('field.naem');
次に、補間引数の渡し忘れがある。翻訳文が {{entity}} を要求していても、引数なしで呼べてしまう。画面に {{entity}}を作成しました と堂々と表示されるまで、誰も気づかない。
t('crud.create.success');

さらに、日本語と英語で補間変数の名前がズレることもある。

// ja
'{{entity}}を作成しました'

// en
'Created {{resource}}'

もう1つ、今回はCoding Agentがコードを書く前提だった。AIは数十ファイルをまとめて書き換える。その全箇所で行儀よく正しく書いてくれると期待するのは、いささか楽観が過ぎるというものだ。それより、間違ったコードがそもそも通らない状態を作っておくほうがずっと効く。

そこで、i18nの導入と一緒に周辺の仕組みまで設計することにした。翻訳リソースはTypeScriptで管理し、言語間で構造が揃っているかを検証する。i18next固有のAPIはアプリケーションに直接さらさず、翻訳キーと補間引数を型安全にする。不整合はテストとCIで止め、未翻訳のハードコード文字列はLintで拾う。

先に規模感を書いておく。自前のLintルールを既存コードに当てたところ、未翻訳の候補は1万件を超えた。この数字の話は後半で改めて書く。

なお前提として、このアプリはReact / TypeScript中心で、翻訳リソースも開発者自身が管理している。翻訳を非エンジニアが管理する現場には、以下の設計はそのままでは合わないだろう。その点は最後にもう一度触れる。

TypeScriptで書く翻訳リソース

そもそも、翻訳リソースはJSONで管理するのが一般的だ。i18nextの CustomTypeOptions を使えば、JSONのリソースにも型は付く。ただ、JSONをimportした値は string に広がるため、後述する補間変数の抽出には使えない。今回は翻訳リソースを型システムにしっかり乗せたかったので、.ts で書くことにした。
export const commonJa = {
  action: {
    create: '作成',
    update: '更新',
    delete: '削除',
    save: '保存',
    cancel: 'キャンセル',
  },

  crud: {
    create: {
      success: '{{entity}}を作成しました',
      failed: '{{entity}}の作成に失敗しました',
    },
    update: {
      success: '{{entity}}を更新しました',
      failed: '{{entity}}の更新に失敗しました',
    },
    delete: {
      success: '{{entity}}を削除しました',
      failed: '{{entity}}の削除に失敗しました',
    },
  },
} as const;
肝は末尾の as const である。これのおかげで、'{{entity}}を作成しました' は string ではなく文字列リテラル型として残る。翻訳キーの型生成も、IDE補完も、補間変数の抽出も、すべてこの型情報の上に乗っている。

キーは英語で、意味を表す名前にしている。

export const projectJa = {
  name: 'プロジェクト',

  field: {
    name: 'プロジェクト名',
    description: '説明',
  },

  message: {
    cannotEditArchived:
      'アーカイブ済みのプロジェクトは編集できません',
  },
} as const;

表示文字列そのものをキーにする流儀もあるが、採用しなかった。

t('プロジェクトを作成しました');

この方式だと、文言を言い換えただけでキーまで変わってしまう。「プロジェクトを作成しました」を「プロジェクトの作成が完了しました」に直す。それだけで、呼び出し側まで書き換える羽目になるわけだ。翻訳キーは表示文言ではなく、意味を識別するIDとして扱いたい。

構造の正本としての日本語

次に気になったのが、言語間のキーの追加漏れだ。日本語に field.description を足したら、英語側にも同じキーがあることを型で強制したい。 素朴に考えれば、typeof projectJa を英語側の型にすればよさそうに見える。ところがこれだと、name: 'プロジェクト' という値まで一致を求められる。英語のリソースに「プロジェクト」と書けというのは、さすがに無茶な注文である。欲しいのは構造の一致だけなのだ。 そういうわけで、文字列のLeafだけを string に広げる型を用意した。
export type TranslationOf<T> =
  T extends string
    ? string
    : T extends object
      ? {
          readonly [K in keyof T]: TranslationOf<T[K]>
        }
      : never;
英語側はこれを satisfies で受ける。
export const projectEn = {
  name: 'Project',

  field: {
    name: 'Project name',
    description: 'Description',
  },
} as const satisfies TranslationOf<typeof projectJa>;
試しに description を書き忘れてみると、
export const projectEn = {
  name: 'Project',

  field: {
    name: 'Project name',
  },
} as const satisfies TranslationOf<typeof projectJa>;

TypeScriptがきちんとエラーにしてくれる。英語にだけ余計なキーを足した場合も同様だ。つまり、日本語のリソースが翻訳構造のSource of Truthになり、他の言語はその形に従う。正本と写しの関係が、ここで出来上がるわけだ。

i18nextの手前に挟む薄いAdapter

もっとも、i18nextにもSelector APIがあり、存在しないキーはTypeScriptが検出してくれる。

const { t } = useTranslation('project');

t(($) => $.field.name);
// ✅ OK

t(($) => $.field.naem);
// ❌ Type Error
これをそのまま使う選択肢もあった。ただ今回は、アプリケーションコードから react-i18next の useTranslation を直接呼ばせていない。代わりに、プロジェクト標準のAPIを1枚挟んでいる。引数はi18nextのNamespaceで、common や project といった単位だ。
const t = useAppTranslation('project');

t('field.name');

理由は2つある。1つは、i18nextへの依存をアプリケーション全体に広げたくなかったこと。もう1つは、Selector APIでは止まらない「補間引数の省略」を止めたかったことだ。後者は次の節で書く。

構造は4段重ねになる。アプリケーションが useAppTranslation() を呼び、内部のi18n Adapterが react-i18next に委譲する。その先で、i18nextが動く。 とはいえ、useAppTranslation は翻訳エンジンを再実装しているわけではない。Fallback、Plural、補間、リソース管理は、すべてi18nextに任せてある。自前で持っているのは、アプリケーションに公開するAPIと型契約だけだ。i18next側のAPIや型定義が変わっても、影響はAdapterの中に閉じ込められる。 Coding Agent向けのルールにも、「useTranslation を直接使わず useAppTranslation を使う」と明記してある。ただし白状しておくと、この境界だけは今のところ指示で守っているに過ぎない。機械的に止める層は、まだないのである。

省略を許さない補間引数

冒頭の commonJa.crud.create.success、つまり '{{entity}}を作成しました' で試してみた。手元のi18next 26.4.2でSelector APIを使うと、結果はこうだった。
t(($) => $.crud.create.success);
// ✅ 通る

t(($) => $.crud.create.success, {
  resource: 'X',
});
// ❌ 型エラー

t(($) => $.crud.create.success, {
  entity: 'X',
});
// ✅ OK
resource を渡すと型エラーになる。つまりi18nextは、{{entity}} という補間変数名を型レベルで認識している。一方で、options引数を省略した1つ目はすんなり通ってしまう。型定義で、optionsがoptionalになっているからだ。

渡せば中身はチェックするが、渡さなくても怒らない。汎用ライブラリとしては、意図的な柔らかさなのだろう。しかしこのアプリケーションでは、補間が必要なキーで引数を忘れるケースを確実に止めたかった。

そこで、補間が必要なキーでは第2引数そのものを必須にした。翻訳リソースは as const なので、'{{entity}}を作成しました' という文字列型から entity を型レベルで抜き出せる。
type InterpolationKeys<S extends string> =
  S extends `${string}{{${infer Variable}}}${infer Rest}`
    ? Variable | InterpolationKeys<Rest>
    : never;

これを使って、補間変数が存在する場合だけ第2引数を要求する。

type InterpolationArgs<V> =
  V extends string
    ? [InterpolationKeys<V>] extends [never]
      ? []
      : [
          values: Record<
            InterpolationKeys<V>,
            string | number
          >
        ]
    : [];
結果、t('crud.create.success') は Expected 2 arguments, but got 1 で落ちる。変数名の間違いも止まり、正しい呼び出しだけが通るようになる。
t('crud.create.success', {
  resource: 'Project',
});
// ❌ Type Error

t('crud.create.success', {
  entity: 'Project',
});
// ✅ OK
これでAdapterが保証するものは3つになった。キーの存在、補間変数名、補間引数の有無である。正直に言えば、t(($) => $.foo.bar) より t('foo.bar') のほうが読みやすい、という個人的な好みもある。ただ主目的はあくまで、i18nextへの直接依存を閉じ込めつつ、アプリケーション側に一段厳しい契約を持たせることだった。

共通化してよい文言、いけない文言

i18n化を進めると、似たような文言が大量に出てくる。「プロジェクトを作成しました」「ユーザーを作成しました」「チームを作成しました」。これをEntityごとに project.message.createSuccess、user.message.createSuccess と定義していけば、たちまち重複の山が築かれる。そこで、機械的なCRUD文言は冒頭で示した commonJa.crud に寄せた。

使う側は、Entity名を別のNamespaceから引いて渡す。

const commonT = useAppTranslation('common');
const projectT = useAppTranslation('project');

commonT('crud.create.success', {
  entity: projectT('name'),
});
英語なら '{{entity}} was created successfully' と語順を変えられる。文字列連結で組み立てると、この語順の違いに対応できない。だから、ここは必ず補間で書くようにしている。 一方、「アーカイブ済みのプロジェクトは編集できません」のような文言は common に入れていない。「アーカイブ済みのProjectは編集できない」というドメインルールを含んでいるからだ。こういうものは、先ほどの projectJa.message.cannotEditArchived のようにEntity側へ置く。 判断基準は単純である。Entity名だけ差し替えれば成立する機械的な文言なら、common に置く。業務知識が含まれた時点で、EntityないしFeature側の持ち物になる。

Sliceに置く翻訳

ProjectというEntityを削除するなら、その翻訳も一緒に消えてほしい。今回はFeature-Sliced Design(FSD)を採用しているので、翻訳リソースを locales/ に集約しなかった。その文言を使う機能単位のディレクトリ、FSDでいうSliceに置いている。 共通の文言は、src/shared/i18n/common/ に ja.ts と en.ts として置く。Projectの文言なら、src/entities/project/i18n/ に ja.ts、en.ts、index.ts として置く。翻訳だけを別世界の住人として扱わない、という考え方である。

型でやらないこと

かといって、なんでも型で解決しようとはしていない。たとえば日本語が '{{entity}}の作成に失敗しました' で、英語が 'Failed to create {{resource}}' になっているケースを考える。オブジェクトの構造は同じなので、satisfies TranslationOf<typeof ja> は通ってしまう。型で止めるなら、TranslationOf に補間変数の比較を足すことになる。そこまで型を複雑にするのは御免蒙りたい。 代わりに、言語間で補間変数が一致しているかはVitestで確かめている。日本語側が entity、英語側が resource を要求していれば、テストが落ちる。単一のリソースを見れば判断できるものはTypeScript、複数のリソースを比べないと分からないものはテスト。複雑な型を延々と増やし続けるより、この分担のほうがよほど保守しやすいのだ。

型をすり抜けるハードコード文字列

ところが、ここまで型を固めても、開発者がそもそも t() を使わなければTypeScriptはなにも言わない。
<Button>保存</Button>

Coding Agentならなおさらだ。ルールに書いてあっても、数十ファイルを一気に変更する途中で、1カ所くらいはしれっと漏れる。そこで、ハードコードされたユーザー向け文字列をLintで検出できないかと考えた。

最初に試したのは、Oxlint(Rust製のLinterで、ESLintの主要ルールを互換実装している)の react/jsx-no-literals だ。既存コードベースに当てると、数千件規模の違反が出た。だが中身を見ると、- や ( )、Copyright © といった、翻訳対象とは言いづらい文字列がかなり混ざっている。複数行のJSXでは開始タグ側に報告されることもあり、どの文字列が問題なのか追いづらい。

逆に、今回拾いたかった次のようなケースは素通りする。

{'保存しました'}
<Input placeholder="名前を入力してください" />
toast({
  message: '保存しました',
});
z.string().min(
  1,
  '名前を入力してください',
);

「JSX内にLiteralがあるか」と「未翻訳のユーザー向け文字列があるか」は、似ているようでまるで別の問いだったのである。

そこでOxlintのJS Pluginで、i18n/no-hardcoded-japanese というルールを自前で拵えた。Literal、TemplateElement、JSXText を対象に、日本語を含む文字列を検出する。上の4ケースに加えて、JSXTextと Error のメッセージも拾う。
<Button>保存</Button>
throw new Error(
  'データの取得に失敗しました',
);
console.error('データの取得に失敗しました') のようなログ用途は除外している。コメントはAST上の文字列リテラルではないので、そもそも対象にならない。

1万件の候補と、育てるルール

このルールを既存コードベース全体に当てると、ハードコード文字列の候補が1万件を超えた。jsx-no-literals の数千件でも多いと思っていたところに、桁が1つ増えたわけだ。見なかったことにして、そっとターミナルを閉じたくなる数字である。場所もPage、Feature、Widget、Validation schema、Error message、Shared UIと多岐にわたった。 殊に多かったのが、z.string().min(1, '入力してください') のようなValidationメッセージと、
const menu = {
  label: '設定',
};

のようなJSXの外にある文字列だ。JSXだけを見るLintルールが見落としていたのは、まさにこの部分だった。

この結果を見て、方針を決めた。すべてのLiteralを一律に禁止する方向には行かない。実際の検出結果を見ながら、翻訳対象ではないコンテキストを少しずつ除外していく。

なにしろ、ハードコード文字列の判定は思ったより難しい。throw new Error('予期しない状態です') は、今のルールでは拾う。とはいえ、それがユーザーに表示されるかどうかは、文字列だけ見ても分からない。対して z.string().min(1, '名前を入力してください') は、ほぼ確実にユーザー向けだ。

だから、最初から完璧なルールを作ろうとはしていない。まず広めに検出し、実際の違反を眺める。明らかなfalse positiveを除外してルールのテストケースに加え、もう一度コードベース全体に当てる。このサイクルを回して、ルールを育てているのだ。

汎用的なLintルールを頭の中だけで設計しても、えてして絵に描いた餅に終わる。実際のコードに大量に当てて精度を上げるほうが、実用的な代物になりやすい。将来的には、日本語に限らずユーザー向けのハードコード文字列全般を検出したい。Oxlint / ESLint互換のPluginとして切り出すことも考えている。

もっとも、候補が1万件ある状態で、最初からすべてをエラーにはできない。現時点では pnpm lint:i18n を独立したコマンドにして、通常の pnpm lint は従来どおり動かしている。移行が進んだら、i18n/no-hardcoded-japanese: error を通常のLintに統合する予定だ。

既存プロジェクトに新しいLintルールを入れるとき、いつも壁になる問題がある。「既存違反が多すぎてCIに入れられない」問題だ。Pluginとして一般化するなら、既存違反はbaselineとして一時的に許容したい。そのうえで新規違反だけをCIのエラーにする段階移行も、サポートしておくべきだろう。

重ねるガードレール

ここまでの結果、i18nの品質保証はいくつもの層に分かれた。いちばん外側にあるのが「useAppTranslation を使う」というプロジェクトルールで、その内側でOxlintが未翻訳の文字列を拾う。さらにTypeScriptが、翻訳キー、Namespace、補間引数を検査する。最後に、VitestとCIがlocaleの構造と補間変数の整合性を確かめる。 AIに「必ず useAppTranslation を使うこと」と書いておいても、間違えるときは間違える。だから、指示、API設計、型システム、Lint、テストを重ねるのだ。 AIが <Button>保存</Button> と書けば、lint:i18n が拾う。t('crud.create.succes') と書けば、TypeScriptが弾く。t('crud.create.success') で補間を忘れれば、引数不足で落ちる。英語側で 'Created {{resource}}' と書けば、今度はテストが赤くなる。どの層で止まっても、AIはエラーを読んで自分で直せる。 見方を変えると、これはAIが生成できるコードの探索空間を狭めることでもある。翻訳APIが t(key: string) なら、Coding Agentはなんでも書けてしまう。
t('create.success');
t('createSuccess');
t('crud.create.success');
t('project.create.success');

これらは全部、TypeScriptとしては成立する。正しいキーしか通らない型になっていれば、最初に間違えてもコンパイラがフィードバックを返してくれる。

OpenAPIからAPIの型を生成する。Zodで入力の境界を定義する。Error CodeをUnionで制限し、exhaustive switchで網羅性を保証する。どれも同じ発想で、Coding Agentを使う開発全体で今後ますます効いてくるはずだ。

書き換わったフォーム

実際のフォームがどう変わったかも見ておく。元のコードは冒頭に出したとおり、message: 'プロジェクトを作成しました' を直書きしていた。i18n対応後はこうなる。
const commonT = useAppTranslation('common');
const projectT = useAppTranslation('project');

const handleSubmit = async (data: ProjectFormData) => {
  reset();

  try {
    await createProject(data);

    showToast({
      type: 'success',
      message: commonT('crud.create.success', {
        entity: projectT('name'),
      }),
    });

    navigate('/projects');
  } catch (err) {
    console.error(err);

    showToast({
      type: 'error',
      message: commonT('crud.create.failed', {
        entity: projectT('name'),
      }),
    });
  }
};

コード量は確実に増えた。その代わり、冒頭で挙げた3つのミスは、このフォームでもすべて型で止まる。

むろん、やりすぎに見える部分もあるだろうし、どこでも通用する構成だとも考えていない。小規模なアプリや対応言語がほぼ増えないアプリなら、JSONと一般的な翻訳管理ツールのほうが向いている。Translation Management Systemを中心に非エンジニアが翻訳を管理するチームや、TypeScript以外のクライアントが多い環境でも、事情は同じはずだ。

FSDへのco-locationも、すべての翻訳文を1画面で見渡したい用途には向かない。今回の環境は、ちょうどこの裏返しだった。だからこそ、この設計がうまくはまったのだ。

以上、Reactにi18nextを入れるだけのつもりが、翻訳リソースの置き方からAdapter、補間の型、FSD、Lint、テストまで設計する羽目になった顛末である。一貫して重視したのは、正しいコードが書かれることを期待せず、間違ったコードが通らない状態を作ることだった。

もっとも、まだ全部の層が効いているわけではない。型とテストは機能している。一方のLintは lint:i18n の別運用で、1万件の候補をちまちま消化している最中だ。Adapterの境界も、指示で守っている段階にとどまる。

このガードレールは、人間が書く場合にも有効だ。ただ、Coding Agentは大量の変更を高速にこなす。だから、型、Lint、テスト、API設計で変更の自由度を絞る価値は、以前より確実に上がっている。AIに「間違えないで」とお願いするより、間違えたらコンパイラが止めてくれるほうが、ずっと頼もしいのだ。