Reactの管理画面にi18nを導入することになり、設計を少し真面目に考えました。

最初は単純に、

showToast({
  type: 'success',
  message: 'プロジェクトを作成しました',
});

のようなハードコードされた文言を、

t('project.createSuccess')

へ置き換えていけば十分だと思っていました。

ただ、実際に設計していくと、単に「多言語対応できる」だけでは物足りなくなりました。

今回特に重視したのは、間違ったi18nコードをできるだけコンパイル時に落とすことです。

例えば、次のコードはすべてTypeScriptで検出したい。

t('field.naem');
// 存在しない翻訳キー

t('crud.create.success');
// {{entity}} が必要なのに渡していない

t('crud.create.success', {
  resource: 'Project',
});
// 必要なのは resource ではなく entity

さらに、

  • 日本語にキーを追加したが英語に追加し忘れた
  • 日本語では {{entity}}、英語では誤って {{resource}} にした
  • AIがプロジェクト標準とは違うi18next APIを使い始めた

といったケースも、できるだけ人間のコードレビューに頼らず検出したい。

背景には、Coding Agentを日常的に利用するようになったこともあります。

人間が数ファイルずつ変更する前提なら、多少緩いAPIでもレビューで気づけるかもしれません。

一方、AIが数十ファイルを一度に変更するようになると、

「正しく書いてくれることを期待する」より、「間違ったコードがそもそも通らない」状態を作る

方が重要になってきます。

最終的には、次の構成にしました。

i18next / react-i18next
        +
TypeScript translation resources
        +
English semantic keys
        +
Japanese resource as schema source
        +
as const
        +
satisfies TranslationOf<typeof ja>
        +
typed useAppTranslation
        +
strict interpolation arguments
        +
FSD co-location
        +
translation consistency tests

この記事では、なぜこの構成にしたのかを順番に説明します。


翻訳リソースはJSONではなくTypeScriptで管理する

i18nでは、次のようなJSONで翻訳を管理する構成が一般的です。

{
  "create": "作成",
  "update": "更新",
  "delete": "削除"
}

もちろんJSONでも問題なく動きます。

ただ、今回は翻訳リソースそのものをTypeScriptの型システムに乗せたかったため、.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 を付けることで、値が単なる string ではなく文字列リテラル型として残ります。

例えば、

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

という情報そのものをTypeScriptの型として利用できます。

これによって、

  • 翻訳キーの型生成
  • IDE補完
  • 補間変数の抽出
  • 他言語との構造比較

が可能になります。


翻訳キーは表示文言ではなく意味で付ける

キーは普通に英語にしました。

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

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

  page: {
    listTitle: 'プロジェクト一覧',
    createTitle: 'プロジェクトを作成',
    editTitle: 'プロジェクトを編集',
  },

  message: {
    duplicateName:
      '同じ名前のプロジェクトが既に存在します',

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

一方で、次のように表示文字列そのものをキーにはしませんでした。

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

例えば表示文言を、

プロジェクトを作成しました

から、

プロジェクトの作成が完了しました

へ変更しただけでキーまで変わってしまうからです。

翻訳キーはUI文言そのものではなく、意味を識別するためのIDとして扱います。


日本語を翻訳構造のSource of Truthにする

次に考えたのが、他言語のキー追加漏れです。

日本語に、

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

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

と書いた場合、英語にも同じ構造を要求したい。

ただし、

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',
  },
} satisfies TranslationOf<typeof projectJa>;

コンパイルエラーになります。

逆に英語だけ余計なキーを追加してもエラーです。

つまり、

Japanese resources
       ↓
Translation schema
       ↓
English resources

という関係にしました。


i18nextのSelector APIではなく、薄いAdapterを置いた

i18nextにはSelector APIがあります。

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

t(($) => $.field.name);

これなら存在しないキーはTypeScriptが検出してくれます。

t(($) => $.field.naem);
// Type Error

型安全性だけを考えるなら、かなりよくできています。

ただ、今回はアプリケーション側から直接 useTranslation を利用せず、独自の useAppTranslation を1枚挟むことにしました。
const t = useAppTranslation('project');

t('field.name');

理由は主に2つあります。

1. i18next固有APIへの依存をアプリ全体へ広げたくない

アプリケーションコードでは、

t('field.name')

というAPIだけを使います。

内部では最終的にi18nextの t に委譲しています。
Component
    ↓
useAppTranslation
    ↓
react-i18next
    ↓
i18next

つまり、i18nextを置き換えるような独自翻訳エンジンを作っているわけではありません。

Fallback、Plural、Interpolation、Resource管理といった翻訳処理はすべてi18nextへ任せます。

自前なのはアプリケーションから見える境界だけです。

これならi18next側のAPIや型定義が変わった場合でも、影響をAdapter内へ閉じ込めやすくなります。

2. Selector APIより補間を厳しくチェックしたかった

ここは実際に試して分かった部分です。


i18nextのSelector APIは補間変数を型チェックする。ただしoptions自体は省略できる

例えば次の翻訳リソースがあるとします。

export const commonJa = {
  crud: {
    create: {
      success: '{{entity}}を作成しました',
    },
  },
} as const;
i18nextのSelector APIは {{entity}} まで認識しています。

手元の i18next 26.4.2 では、実際に次の結果になりました。

t(($) => $.crud.create.success);
// ✅ 通る

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

t(($) => $.crud.create.success, {
  entity: 'X',
});
// ✅ OK

これは結構面白い挙動です。

2つ目が落ちているので、i18nextは

{{entity}}

という補間変数名を型レベルで把握しています。

しかし、

t(($) => $.crud.create.success);

は通ります。

Selector APIの型定義ではoptions引数そのものがoptionalだからです。

つまり、

補間引数を渡した場合
→ 中身は型チェックされる

補間引数自体を渡さなかった場合
→ 通る

という状態です。

これはバグというより、i18next側のAPIとして意図的にある程度柔軟にしている部分だと思います。

ただ、今回のアプリケーションではもう少し厳しくしたいと考えました。


補間が必要なら第2引数そのものを必須にする

TypeScriptで翻訳リソースを 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
          >
        ]
    : [];
これによって useAppTranslation では、
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では、

翻訳キーの存在
      +
補間変数名
      +
補間引数そのものの存在

までコンパイル時に保証します。

Selector APIとの違いを並べると分かりやすいです。

// i18next Selector API
t(($) => $.crud.create.success);
// ✅ options自体は省略できる

t(($) => $.crud.create.success, {
  resource: 'X',
});
// ❌ 補間変数名はチェックされる

// useAppTranslation
t('crud.create.success');
// ❌ 補間が必要なので第2引数自体が必須
useAppTranslation を残したのは、単に
t(($) => $.foo.bar)

より

t('foo.bar')

の方が好みだったからではありません。

i18nextへの依存を境界内に閉じ込めながら、アプリ側では一段厳しい契約を定義できることにメリットがありました。


CRUDメッセージは共通化する

i18n化を進めると、似た文言が大量に出てきます。

プロジェクトを作成しました
ユーザーを作成しました
チームを作成しました

これを各Entityで、

project.message.createSuccess
user.message.createSuccess
team.message.createSuccess

と定義すると重複が多くなります。

そこで機械的なCRUDメッセージは common に寄せました。
export const commonJa = {
  crud: {
    create: {
      success: '{{entity}}を作成しました',
      failed: '{{entity}}の作成に失敗しました',
    },
    update: {
      success: '{{entity}}を更新しました',
      failed: '{{entity}}の更新に失敗しました',
    },
  },
} as const;

使う側は、

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

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

英語なら、

success: '{{entity}} was created successfully',

のように翻訳側で語順を変更できます。

文字列連結をしないことも重要です。


ただし業務ルールまでcommonへ押し込まない

何でも共通化するわけではありません。

例えば、

アーカイブ済みのプロジェクトは編集できません

という文言。

これは単なるUI文言ではなく、

アーカイブ済みProjectは編集できない

というドメインルールを含んでいます。

そのため、これはEntity側へ置きます。

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

判断基準は単純です。

Entity名だけ差し替えれば成立する機械的な文言は common。 業務知識が含まれた時点でEntity / Feature側。

例えば、

{{entity}}を削除しました
common

一方、

承認済みのプロジェクトは削除できません
なら project 側です。

翻訳リソースもFSDのSliceへ置く

翻訳ファイルを、

locales/
├── ja.json
└── en.json

へすべて集約する方法もあります。

今回はFeature-Sliced Designを採用しているため、翻訳も意味を所有するSliceへ置くことにしました。

src/
├── app/
│   └── i18n/
│
├── shared/
│   └── i18n/
│       ├── common/
│       │   ├── ja.ts
│       │   └── en.ts
│       └── validation/
│
├── entities/
│   └── project/
│       └── i18n/
│           ├── ja.ts
│           ├── en.ts
│           └── index.ts
│
└── features/
    └── create-project/
        └── i18n/
            ├── ja.ts
            └── en.ts

例えばProjectというEntityを削除するなら、その翻訳も同じSliceから一緒に削除できます。

考え方としては、

翻訳だけを別世界として管理するのではなく、その文言の意味を所有しているモジュールが翻訳も所有する

というものです。


TypeScriptで自然に保証できないものはテストする

型安全を重視していますが、何でもTypeScriptの型パズルで解決しようとはしていません。

例えば、

// ja
'{{entity}}の作成に失敗しました'

に対して、

// en
'Failed to create {{resource}}'

としてしまったケース。

翻訳オブジェクトの構造自体は同じなので、

satisfies TranslationOf<typeof ja>

では検出できません。

そこで、言語間でInterpolation parameterが一致していることはテストで確認しています。

ja → entity
en → resource

→ Test Failed

役割を分けています。

単一リソースだけ見れば判断できる
          ↓
      TypeScript

複数リソースを比較する必要がある
          ↓
         Test

この方が、複雑な型を増やし続けるより保守しやすいと考えています。


Coding Agent時代では「型安全」の意味が少し変わった

今回ここまでガードレールを作った理由の一つが、Coding Agentの利用です。

従来、型安全というと、

人間の開発者のミスを防ぐ

という文脈が中心でした。

もちろん今でもそれは重要です。

ただ、AIがコードを書くようになると、もう一つ役割が増えます。

AIが生成できるコードの探索空間を狭める

という役割です。

例えば翻訳APIが、

t(key: string)

だった場合、Coding Agentは何でも書けます。

t('create.success');
t('createSuccess');
t('crud.create.success');
t('project.create.success');

全部TypeScriptとして成立してしまいます。

一方、

t('crud.create.success', {
  entity: projectT('name'),
});

しか通らない型になっていれば、AIが最初に間違えても、

AIがコードを書く
      ↓
TypeScriptが拒否する
      ↓
AIがエラーを読む
      ↓
修正する

というループを作れます。

つまり、コンパイラをCoding Agentのフィードバック機構として利用できます。


AIへのガードレールは1層ではなく複数層にする

今回のi18nでは、結果的に次のような構造になりました。

Project Skill / Coding Rules
        ↓
「useAppTranslationを使う」と教える

Adapter
        ↓
利用可能なAPIを限定する

TypeScript
        ↓
間違ったキーや補間を拒否する

Test / CI
        ↓
言語間の不整合を拒否する

例えばプロジェクトのCoding Agent向けルールには、

// ❌ i18next に直接依存する
const { t } = useTranslation('common');

// ✅ プロジェクト標準のAdapterを利用する
const t = useAppTranslation('common');

というルールを持たせています。

ただし、AIへの指示だけには依存しません。

AIはルールを読み間違えることもあります。

そこで、

Instruction
   +
API design
   +
Type system
   +
Tests

を重ねます。

これはi18nだけに限った話ではありません。

例えば、

  • OpenAPIからAPI型を生成する
  • Zodで入力境界を定義する
  • typed routeを利用する
  • DB schemaから型を生成する
  • Error CodeをUnionで制限する
  • exhaustive switchで網羅性を保証する

といった設計も同じ考え方です。

AIに正しいコードを書かせるためにプロンプトを工夫するだけではなく、

正しくないコードが通らない開発環境を作る。

Coding Agentを多用するほど、この考え方は重要になると感じています。


実際のコードはどう変わったか

例えば、元々こんなフォームがあったとします。

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

  try {
    await createProject(data);

    showToast({
      type: 'success',
      message: 'プロジェクトを作成しました',
    });

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

    showToast({
      type: 'error',
      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'),
        },
      ),
    });
  }
};

記述量だけを比較すると増えています。

ただし、

commonT('crud.create.succes', ...);
// 翻訳キーが間違っている → Type Error
commonT('crud.create.success');
// entity不足 → Type Error
commonT('crud.create.success', {
  resource: projectT('name'),
});
// 補間変数名が違う → Type Error
export const projectEn = {
  // 日本語に存在するキーが不足
};
// satisfies → Type Error

という状態になります。

Coding Agentが大量にコードを変更する環境では、この「失敗の速さ」に価値があると考えています。


デメリットもある

もちろん、この構成がすべてのプロジェクトに適しているとは思いません。

まず、JSON管理よりコード量は増えます。

TranslationOf<T>
InterpolationKeys<T>
useAppTranslation(...)

といった独自コードも必要です。

また、翻訳担当者が非エンジニアの場合、TSファイルを直接編集する方式は使いづらいでしょう。

多言語数が多く、専用の翻訳チームやTranslation Management Systemを使うようなプロジェクトなら、JSONなどの標準的なフォーマットの方が適している可能性もあります。

FSDへのco-locationも、

すべての翻訳文を一画面で確認したい

という用途には向いていません。

今回の構成は、

  • TypeScript中心
  • 翻訳リソースも開発者が管理する
  • 対応言語数が比較的少ない
  • 型安全性を重視する
  • FSDを採用している
  • Coding Agentによるコード変更が多い

というプロジェクトだから採用したものです。


まとめ

最初は単純なi18n導入のつもりでした。

しかし実際に考えていくと、

翻訳キー
言語間の構造
Interpolation
ライブラリへの依存
翻訳の責務
Coding Agentへのガードレール

まで設計することになりました。

最終的に特に重視したのは、

正しいコードを書くことを期待するのではなく、間違ったコードが通らない状態を作る

ことです。

i18next自体もかなり型安全です。

Selector APIでは翻訳キーだけでなく補間変数名まで型チェックされます。

一方、今回確認したi18next 26.4.2では、

t(($) => $.crud.create.success);

のように補間用options自体を省略することはできました。

そこで今回は薄い useAppTranslation を境界に置き、
t('crud.create.success');
// Type Error

まで厳しくしました。

ただし翻訳処理そのものを再実装したわけではありません。

i18next
    ↓
翻訳エンジンとして利用

useAppTranslation
    ↓
アプリケーション向けの契約を定義

TypeScript / Test / CI
    ↓
契約違反を拒否

という役割分担です。

人間がコードを書く場合にもこうしたガードレールは有効でした。

ただ、Coding Agentが大量の変更を高速に行うようになったことで、型・テスト・API設計によって変更の自由度を適切に制約する価値は以前より高くなったと感じています。

AIに「間違えないで」とお願いするより、

間違えたらコンパイラが止める。

今後は、こうした設計がより重要になっていくのではないかと思います。