TL;DR
- 設計段階から多言語化を考慮することで、スムーズな国際展開が可能
- フロントエンドとバックエンドで役割分担を明確にする
- 翻訳キーの命名規則と型安全性が運用の鍵
- 本記事では NestJS + React Native/Expo での実装を具体的に解説
この記事の対象読者
- グローバル展開を見据えたアプリを開発中の方
- NestJS / React Native を使っている方
- 翻訳キー設計のベストプラクティスを知りたい方
1. なぜ多言語化が必要か
グローバル市場を見据えたアプリケーション開発において、多言語化は重要な要素です。設計段階から多言語化を意識することで、スムーズな国際展開が可能になります。
この記事で学べること
- フロントエンドとバックエンドの役割分担
- 技術選定の判断基準と比較
- 翻訳キー設計のベストプラクティス
- 運用フローとチーム協業の方法
2. 多言語化の全体像
どこで翻訳するか?
┌─────────────────────────────────────────────────────────┐
│ 多言語化の対象 │
├─────────────────────────────────────────────────────────┤
│ フロントエンド │ バックエンド │
│ - UI ラベル │ - Push通知 │
│ - ボタンテキスト │ - メール本文 │
│ - バリデーションメッセージ │ - SMS │
│ - エラー表示 │ - PDF生成 │
│ - 日付・数値フォーマット │ - APIエラーメッセージ(※)│
└─────────────────────────────────────────────────────────┘
※ APIエラーはコードを返し、フロントで翻訳する方式を推奨
推奨アーキテクチャ
[フロントエンド] [バックエンド]
┌─────────────────┐ ┌─────────────────┐
│ UI翻訳 │ │ 通知/メール翻訳 │
│ (i18next) │ │ (nestjs-i18n) │
└────────┬────────┘ └────────┬────────┘
│ │
│ x-lang: ja (ヘッダー) │
└──────────────────────────────────┘
│
言語設定はフロントで管理
設計ポイント:
- フロントエンド: ユーザーの言語設定を管理・保存
- バックエンド: リクエストヘッダーから言語を受け取るだけ
- エラーコード方式: APIはエラーコードを返し、フロントで翻訳
言語検出の戦略
| 方式 | メリット | デメリット | 推奨用途 |
|---|---|---|---|
| ブラウザ設定 | 設定不要 | ユーザーの意図と異なる場合あり | 初回表示のデフォルト |
| ユーザー設定 | 明示的な選択 | 設定画面が必要 | メイン方式 |
| URL パス | SEO に有利 | 実装が複雑 | Web サイト |
| サブドメイン | 完全な分離 | インフラが複雑 | 大規模サービス |
モバイルアプリの推奨フロー:
// 1. アプリ起動時
const savedLang = await AsyncStorage.getItem('userLanguage');
const deviceLang = Localization.locale.split('-')[0]; // 'ja-JP' → 'ja'
const currentLang = savedLang || deviceLang || 'ja';
// 2. APIリクエスト時
const headers = {
'x-lang': currentLang,
};
// 3. ユーザーが設定変更時
await AsyncStorage.setItem('userLanguage', 'en');
3. 技術選定
フロントエンドライブラリ比較
| 機能 | i18next | react-intl | expo-localization |
|---|---|---|---|
| 型安全性 | TypeScript 対応 | TypeScript 対応 | 基本的 |
| 複数形対応 | ICU 形式 | ICU 形式 | なし |
| 補間(変数埋め込み) | {{name}} |
{name} |
なし |
| ネスト翻訳 | 対応 | 非対応 | なし |
| 名前空間 | 対応 | 非対応 | なし |
| バンドルサイズ | 約 40KB | 約 45KB | 軽量 |
| 学習コスト | 中 | 中 | 低 |
| エコシステム | 豊富 | 豊富 | Expo 限定 |
| React Native 対応 | react-i18next | 対応 | 専用 |
おすすめ: i18next + react-i18next
理由:
- 名前空間で翻訳ファイルを分割できる
- ネスト構造をサポート
- TypeScript との統合が優秀
- Expo でも React Native CLI でも動作
バックエンドライブラリ比較
| 機能 | nestjs-i18n | node-polyglot | i18next |
|---|---|---|---|
| NestJS 統合 | ネイティブ | 手動設定必要 | 手動設定必要 |
| GraphQL 対応 | 対応 | なし | なし |
| 型生成 | 自動生成可能 | なし | プラグイン必要 |
| リゾルバー | 複数対応 | なし | なし |
| ファイル監視 | 対応 | なし | プラグイン必要 |
| 学習コスト | 低(NestJS ユーザー) | 低 | 中 |
おすすめ: nestjs-i18n
理由:
- NestJS のエコシステムにネイティブ統合
- HeaderResolver で簡単に言語検出
- 型生成で翻訳キーの補完が効く
推奨の組み合わせ
┌─────────────────────────────────────────────────────────┐
│ フロントエンド: i18next + react-i18next │
│ バックエンド: nestjs-i18n │
│ 翻訳ファイル: JSON(フラット + 機能別分割) │
└─────────────────────────────────────────────────────────┘
4. 翻訳キー設計のベストプラクティス
フラット vs ネスト構造
// フラット構造
{
"user_profile_title": "プロフィール",
"user_profile_edit_button": "編集",
"user_settings_title": "設定"
}
// ネスト構造(推奨)
{
"user": {
"profile": {
"title": "プロフィール",
"editButton": "編集"
},
"settings": {
"title": "設定"
}
}
}
ネスト構造のメリット:
- 階層が視覚的にわかりやすい
- 関連するキーがまとまる
- IDEの補完が効きやすい
命名規則
feature.component.element
| レベル | 説明 | 例 |
|---|---|---|
| feature | 機能・ドメイン | user, auth, notification |
| component | コンポーネント・画面 | profile, settings, form |
| element | 具体的な要素 | title, button, error, placeholder |
実例:
{
"auth": {
"login": {
"title": "ログイン",
"emailPlaceholder": "メールアドレス",
"passwordPlaceholder": "パスワード",
"submitButton": "ログイン",
"forgotPassword": "パスワードをお忘れですか?",
"errors": {
"invalidCredentials": "メールアドレスまたはパスワードが正しくありません",
"accountLocked": "アカウントがロックされています"
}
},
"register": {
"title": "新規登録",
"submitButton": "登録"
}
}
}
型安全な翻訳キー(TypeScript統合)
Step 1: 翻訳ファイルを定義
// src/locales/ja/common.ts
export const common = {
buttons: {
submit: '送信',
cancel: 'キャンセル',
save: '保存',
},
errors: {
required: '必須項目です',
invalidEmail: '有効なメールアドレスを入力してください',
},
} as const;
Step 2: 型定義を生成
// src/locales/types.ts
import { common } from './ja/common';
export type TranslationKeys = typeof common;
// ネストしたキーをドット区切りの文字列に変換
type NestedKeyOf<T, K extends keyof T = keyof T> = K extends string
? T[K] extends Record<string, unknown>
? `${K}.${NestedKeyOf<T[K]>}`
: K
: never;
export type TranslationKey = NestedKeyOf<TranslationKeys>;
// 結果: "buttons.submit" | "buttons.cancel" | "buttons.save" | "errors.required" | ...
Step 3: 型安全なフック
// src/hooks/useTypedTranslation.ts
import { useTranslation } from 'react-i18next';
import { TranslationKey } from '../locales/types';
export function useTypedTranslation() {
const { t, i18n } = useTranslation();
const typedT = (key: TranslationKey, options?: object): string => {
return t(key, options);
};
return { t: typedT, i18n };
}
使用例:
const { t } = useTypedTranslation();
// 型チェックが効く
t('buttons.submit'); // OK
t('buttons.typo'); // コンパイルエラー!
補間(変数埋め込み)と複数形対応
変数埋め込み:
{
"welcome": "こんにちは、{{name}}さん",
"itemCount": "{{count}}件の商品"
}
t('welcome', { name: 'ユーザー' });
// → "こんにちは、ユーザーさん"
複数形対応(ICU形式):
{
"notification": "{count, plural, =0 {通知はありません} one {1件の通知} other {#件の通知}}"
}
t('notification', { count: 0 }); // → "通知はありません"
t('notification', { count: 1 }); // → "1件の通知"
t('notification', { count: 5 }); // → "5件の通知"
日本語の場合の注意: 日本語には複数形がないので、シンプルな補間で十分なケースが多い。
{
"notification_ja": "{{count}}件の通知",
"notification_en": "{count, plural, =0 {No notifications} one {1 notification} other {# notifications}}"
}
5. ファイル構成パターン
パターン1: 言語別ファイル(小規模向け)
src/locales/
├── ja.json
├── en.json
└── ko.json
メリット: シンプル、翻訳者に渡しやすい
デメリット: ファイルが巨大化する
パターン2: 機能別 + 言語別(中〜大規模向け、推奨)
src/locales/
├── ja/
│ ├── common.json # 共通UI
│ ├── auth.json # 認証
│ ├── user.json # ユーザー
│ ├── notification.json # 通知
│ └── validation.json # バリデーション
├── en/
│ ├── common.json
│ ├── auth.json
│ └── ...
└── ko/
└── ...
メリット:
- 機能追加時に該当ファイルだけ編集
- コードレビューが容易
- 遅延読み込み可能
JSON vs YAML vs TypeScript
| 形式 | メリット | デメリット |
|---|---|---|
| JSON | 標準的、ツール対応多 | コメント不可 |
| YAML | 可読性高、コメント可 | パーサー必要 |
| TypeScript | 型安全、補完効く | 翻訳者には難しい |
推奨: 開発時は TypeScript、翻訳者への受け渡しは JSON にエクスポート
ディレクトリ構成例(フルスタック)
# フロントエンド (React Native / Expo)
app/
├── src/
│ ├── locales/
│ │ ├── ja/
│ │ │ ├── common.json
│ │ │ ├── auth.json
│ │ │ └── ...
│ │ ├── en/
│ │ └── index.ts # 初期化
│ ├── hooks/
│ │ └── useTypedTranslation.ts
│ └── ...
# バックエンド (NestJS)
api/
├── src/
│ ├── i18n/
│ │ ├── ja/
│ │ │ ├── notifications.json # Push通知
│ │ │ └── emails.json # メール
│ │ └── en/
│ └── ...
6. 実装例
フロントエンド(React Native + i18next)
Step 1: インストール
npm install i18next react-i18next
Step 2: 初期化
// src/locales/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import jaCommon from './ja/common.json';
import jaAuth from './ja/auth.json';
import enCommon from './en/common.json';
import enAuth from './en/auth.json';
const resources = {
ja: {
common: jaCommon,
auth: jaAuth,
},
en: {
common: enCommon,
auth: enAuth,
},
};
i18n
.use(initReactI18next)
.init({
resources,
lng: 'ja',
fallbackLng: 'ja',
ns: ['common', 'auth'],
defaultNS: 'common',
interpolation: {
escapeValue: false,
},
});
export default i18n;
Step 3: アプリで使用
// App.tsx
import './locales';
import { useTranslation } from 'react-i18next';
function LoginScreen() {
const { t } = useTranslation('auth');
return (
<View>
<Text>{t('login.title')}</Text>
<TextInput placeholder={t('login.emailPlaceholder')} />
<Button title={t('login.submitButton')} />
</View>
);
}
Step 4: 言語切り替え
import { useTranslation } from 'react-i18next';
import AsyncStorage from '@react-native-async-storage/async-storage';
function LanguageSelector() {
const { i18n } = useTranslation();
const changeLanguage = async (lang: string) => {
await i18n.changeLanguage(lang);
await AsyncStorage.setItem('userLanguage', lang);
};
return (
<View>
<Button title="日本語" onPress={() => changeLanguage('ja')} />
<Button title="English" onPress={() => changeLanguage('en')} />
</View>
);
}
バックエンド(NestJS + nestjs-i18n)
Step 1: インストール
npm install nestjs-i18n
Step 2: モジュール設定
// src/i18n/i18n.module.ts
import { Global, Module } from '@nestjs/common';
import { HeaderResolver, I18nModule } from 'nestjs-i18n';
import { join } from 'path';
@Global()
@Module({
imports: [
I18nModule.forRoot({
fallbackLanguage: 'ja',
loaderOptions: {
path: join(process.cwd(), 'dist', 'i18n'),
watch: process.env.NODE_ENV !== 'production',
},
resolvers: [new HeaderResolver(['x-lang'])],
}),
],
})
export class I18nConfigModule {}
Step 3: 翻訳ファイル作成
// src/i18n/ja/notifications.json
{
"welcome": "{{name}}さん、ようこそ!",
"newMessage": "{{sender}}さんからメッセージが届きました",
"reminder": "予約の{{minutes}}分前です"
}
// src/i18n/en/notifications.json
{
"welcome": "Welcome, {{name}}!",
"newMessage": "You have a new message from {{sender}}",
"reminder": "{{minutes}} minutes until your appointment"
}
Step 4: サービスで使用
// src/notification/notification.service.ts
import { Injectable } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
@Injectable()
export class NotificationService {
constructor(private readonly i18n: I18nService) {}
getWelcomeMessage(userName: string, lang: string): string {
return this.i18n.translate('notifications.welcome', {
lang,
args: { name: userName },
});
}
getNewMessageNotification(senderName: string, lang: string): string {
return this.i18n.translate('notifications.newMessage', {
lang,
args: { sender: senderName },
});
}
}
Step 5: API コントローラーでの使用
// src/notification/notification.controller.ts
import { Controller, Get, Headers } from '@nestjs/common';
import { NotificationService } from './notification.service';
@Controller('notifications')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
@Get('welcome')
getWelcome(
@Headers('x-lang') lang: string = 'ja',
) {
return {
message: this.notificationService.getWelcomeMessage('ユーザー', lang),
};
}
}
7. 運用・チーム協業
翻訳者との連携フロー
┌─────────────────────────────────────────────────────────┐
│ 1. 開発者: 新しい翻訳キーを追加(日本語 + キー) │
│ ↓ │
│ 2. CI: 翻訳漏れチェック(他言語にキーがあるか) │
│ ↓ │
│ 3. JSON エクスポート → 翻訳者に送付 │
│ ↓ │
│ 4. 翻訳者: 翻訳完了 → JSON返却 │
│ ↓ │
│ 5. 開発者: インポート → PR作成 │
│ ↓ │
│ 6. レビュー → マージ │
└─────────────────────────────────────────────────────────┘
翻訳漏れの検出(CI)
// scripts/check-missing-translations.ts
import fs from 'fs';
import path from 'path';
const LOCALES_DIR = './src/locales';
const BASE_LANG = 'ja';
const TARGET_LANGS = ['en', 'ko'];
function getKeys(obj: object, prefix = ''): string[] {
const keys: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
keys.push(...getKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
function checkMissingTranslations() {
const baseDir = path.join(LOCALES_DIR, BASE_LANG);
const files = fs.readdirSync(baseDir).filter(f => f.endsWith('.json'));
let hasError = false;
for (const file of files) {
const basePath = path.join(baseDir, file);
const baseContent = JSON.parse(fs.readFileSync(basePath, 'utf-8'));
const baseKeys = getKeys(baseContent);
for (const lang of TARGET_LANGS) {
const targetPath = path.join(LOCALES_DIR, lang, file);
if (!fs.existsSync(targetPath)) {
console.error(`Missing file: ${targetPath}`);
hasError = true;
continue;
}
const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
const targetKeys = getKeys(targetContent);
const missingKeys = baseKeys.filter(k => !targetKeys.includes(k));
if (missingKeys.length > 0) {
console.error(`Missing keys in ${lang}/${file}:`);
missingKeys.forEach(k => console.error(` - ${k}`));
hasError = true;
}
}
}
if (hasError) {
process.exit(1);
}
console.log('All translations are complete!');
}
checkMissingTranslations();
GitHub Actions での実行:
# .github/workflows/check-translations.yml
name: Check Translations
on:
pull_request:
paths:
- 'src/locales/**'
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx ts-node scripts/check-missing-translations.ts
機械翻訳との併用
// scripts/auto-translate.ts
// DeepL API を使った自動翻訳の例
import Deepl from 'deepl-node';
const translator = new Deepl.Translator(process.env.DEEPL_API_KEY!);
async function translateMissing(
sourceText: string,
sourceLang: 'ja',
targetLang: 'en' | 'ko',
): Promise<string> {
const result = await translator.translateText(
sourceText,
sourceLang,
targetLang === 'en' ? 'en-US' : 'ko',
);
return result.text;
}
// 使用例
const translated = await translateMissing('ようこそ', 'ja', 'en');
console.log(translated); // "Welcome"
注意: 機械翻訳は下書きとして使い、必ずネイティブチェックを行う。
8. よくある落とし穴
落とし穴1: ハードコードされた文字列の見落とし
// 見落としがちな箇所
const message = 'エラーが発生しました'; // ハードコード
// Alert
Alert.alert('確認', '本当に削除しますか?'); // ハードコード
// console.log(本番に残っている場合)
console.log('処理開始'); // ハードコード
// テストコード
expect(result.message).toBe('成功しました'); // ハードコード
対策: ESLint ルールで検出
// .eslintrc.js
module.exports = {
rules: {
// 日本語文字列を検出
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/[\\u3000-\\u303f\\u3040-\\u309f\\u30a0-\\u30ff\\uff00-\\uff9f\\u4e00-\\u9faf]/]',
message: 'Japanese strings should use i18n.',
},
],
},
};
落とし穴2: 日付・数値フォーマットの罠
// 日付
new Date().toLocaleDateString();
// 日本: 2024/1/15
// アメリカ: 1/15/2024
// 数値
(1234567.89).toLocaleString();
// 日本: 1,234,567.89
// ドイツ: 1.234.567,89
対策: ロケールを明示的に指定
import { format } from 'date-fns';
import { ja, enUS } from 'date-fns/locale';
const locales = { ja, en: enUS };
function formatDate(date: Date, lang: string): string {
return format(date, 'yyyy/MM/dd', {
locale: locales[lang] || ja,
});
}
function formatNumber(num: number, lang: string): string {
return new Intl.NumberFormat(lang).format(num);
}
落とし穴3: RTL言語への対応
アラビア語やヘブライ語は右から左(RTL)に読む。
// RTL対応が必要な場合
import { I18nManager } from 'react-native';
function setRTL(isRTL: boolean) {
I18nManager.forceRTL(isRTL);
// アプリの再起動が必要
}
// スタイルの注意点
const styles = StyleSheet.create({
container: {
// NG: left/right を直接使う
marginLeft: 16,
// OK: start/end を使う
marginStart: 16,
},
});
落とし穴4: 翻訳キーの重複・不整合
// auth.json
{
"submitButton": "送信"
}
// user.json
{
"submitButton": "送信する" // 同じ意味なのに異なる訳
}
対策: 共通の翻訳は common.json に集約
// common.json
{
"buttons": {
"submit": "送信",
"cancel": "キャンセル",
"save": "保存"
}
}
9. まとめ
多言語化チェックリスト
設計段階:
- フロントエンド/バックエンドの役割分担を決定
- 翻訳キーの命名規則を策定
- ファイル構成パターンを選択
- 型安全な翻訳の仕組みを導入
実装段階:
- ライブラリをインストール・設定
- 共通翻訳ファイルを作成
- 言語切り替え機能を実装
- 日付・数値フォーマットをロケール対応
運用段階:
- 翻訳漏れ検出の CI を設定
- 翻訳者との連携フローを確立
- 新規文字列追加のガイドラインを共有
段階的な導入戦略
Phase 1: 基盤構築
├── ライブラリ導入
├── 共通翻訳(ボタン、エラー)
└── 日本語のみで動作確認
Phase 2: 翻訳拡充
├── 画面ごとの翻訳ファイル作成
├── 英語翻訳の追加
└── CI での翻訳漏れチェック
Phase 3: 運用最適化
├── 翻訳者連携フローの確立
├── 機械翻訳の併用
└── 追加言語(韓国語、中国語など)
多言語化は「後からやる」と工数が膨らみます。最初から設計に組み込むことで、スムーズなグローバル展開が可能になります。