0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【実践ガイド】NestJS + React Native で実現する多言語化(i18n)設計

Posted at

TL;DR

  • 設計段階から多言語化を考慮することで、スムーズな国際展開が可能
  • フロントエンドとバックエンドで役割分担を明確にする
  • 翻訳キーの命名規則と型安全性が運用の鍵
  • 本記事では NestJS + React Native/Expo での実装を具体的に解説

この記事の対象読者

  • グローバル展開を見据えたアプリを開発中の方
  • NestJS / React Native を使っている方
  • 翻訳キー設計のベストプラクティスを知りたい方

1. なぜ多言語化が必要か

グローバル市場を見据えたアプリケーション開発において、多言語化は重要な要素です。設計段階から多言語化を意識することで、スムーズな国際展開が可能になります。

この記事で学べること

  • フロントエンドとバックエンドの役割分担
  • 技術選定の判断基準と比較
  • 翻訳キー設計のベストプラクティス
  • 運用フローとチーム協業の方法

2. 多言語化の全体像

どこで翻訳するか?

┌─────────────────────────────────────────────────────────┐
│                    多言語化の対象                        │
├─────────────────────────────────────────────────────────┤
│ フロントエンド              │ バックエンド               │
│ - UI ラベル                 │ - Push通知                 │
│ - ボタンテキスト            │ - メール本文               │
│ - バリデーションメッセージ  │ - SMS                      │
│ - エラー表示                │ - PDF生成                  │
│ - 日付・数値フォーマット    │ - APIエラーメッセージ(※)│
└─────────────────────────────────────────────────────────┘

※ APIエラーはコードを返し、フロントで翻訳する方式を推奨

推奨アーキテクチャ

[フロントエンド]                    [バックエンド]
┌─────────────────┐                ┌─────────────────┐
│ UI翻訳          │                │ 通知/メール翻訳 │
│ (i18next)       │                │ (nestjs-i18n)   │
└────────┬────────┘                └────────┬────────┘
         │                                  │
         │ x-lang: ja (ヘッダー)            │
         └──────────────────────────────────┘
                         │
                  言語設定はフロントで管理

設計ポイント:

  1. フロントエンド: ユーザーの言語設定を管理・保存
  2. バックエンド: リクエストヘッダーから言語を受け取るだけ
  3. エラーコード方式: 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: 運用最適化
├── 翻訳者連携フローの確立
├── 機械翻訳の併用
└── 追加言語(韓国語、中国語など)

多言語化は「後からやる」と工数が膨らみます。最初から設計に組み込むことで、スムーズなグローバル展開が可能になります。


参考リンク

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?