コンテンツへスキップ

小規模な Electron i18n レイヤーを保守可能に保った六つの判断

ロケール設定、型付きカタログ、フォールバック動作、Intl によるフォーマット、レンダラーからメインプロセスへの同期を扱う、実用的な Electron i18n の設計。

13分で読めますガイド作成者:Temposhot Team

5〜10 言語を扱うデスクトップアプリに、大規模な i18n フレームワークが必要になることはまれです。ただし、明確なルールは数個必要です。Electron アプリには、テキストを表示するプロセスが二つあるためです。レンダラーは UI を描画し、メインプロセスはメニュー、ダイアログ、通知を管理します。以下の六つの判断により、アプリとカタログが大きくなっても、手書きの小さな i18n レイヤーが正しく動作し続けます。

1. ロケールだけでなく設定値を保存する

ユーザーがドイツ語を選んだときに "de" を保存する方法は、一見十分に見えます。しかし、後からアプリを OS の言語に追従させたくなった時点で問題になります。代わりに、ユーザーの選択意図を保存し、実際のロケールは実行時に解決します:

export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;

const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };

export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
  if (pref !== "system") return pref;
  for (const tag of systemLanguages) {
    const lower = tag.toLowerCase();
    const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
    if (exact) return exact;
    if (ALIASES[lower]) return ALIASES[lower];
  }
  return "en";
}

メインプロセスでは、app.getPreferredSystemLanguages() が OS の優先言語リストを順序どおりに返します。"system" が保存されている場合、OS の言語を変更したユーザーは、アプリの設定を変更しなくても次回起動時に新しい言語を使えます。

リゾルバーは、リストの先頭だけでなく全体を順に確認します。「ポルトガル語、次に英語」を設定しているユーザーは英語になります。英語がデフォルトだからではなく、それが二番目の希望だからです。前方一致により、de-CH はドイツ語に、zh-Hans-CN は簡体字中国語に対応します。zh-TW はどれにも一致しないため、簡体字中国語に黙って変換されず、次の希望に進みます。

解決順序

関数は最初に一致した時点で値を返します。保存済みのロケールがシステムのリストによって上書きされることがあってはならないため、確認の順序が重要です。

resolveLocale でロケールを解決する五つの手順。保存済みのロケールを直接返す、システムのリストを先頭から順に読む、SUPPORTED に対して完全一致または前方一致を判定する、zh-cn などのタグを ALIASES 表で確認する、一致がない場合は en を返す。

システムのリストに対応言語が二つ含まれる場合、先に出てきた方が優先されます。macOS は「言語と地域」で設定した順序でリストを保持するため、最初の対応言語がユーザーの第一希望の対応言語になります。

保存された設定の三つの例

次の表は、同じ関数が三人のユーザーをどう扱うかを示します。各行は、保存値と OS のリストを渡した resolveLocale の呼び出しです。

三人のユーザーの比較。システム設定が fr-FR、de-DE、en-US の場合はドイツ語になる。保存された日本語設定は日本語のまま。システム設定が zh-TW と en-GB の場合は英語になる。

ユーザー 1 の第一希望はフランス語ですが、アプリはフランス語を提供していません。ループは fr-FR をスキップし、二番目の要素の de-DE に一致して、ドイツ語を返します。ユーザー 3 は別の理由で英語になります。zh-TW は別名表に含まれず、簡体字中国語にも前方一致しないため、ループは en-GB まで進み、英語を返します。ユーザー 2 はシステムのリストを読みません。保存値が "system" ではなくロケールだからです。

言語の削除後に保存されている値

後のリリースで言語を削除することがあります。その場合、保存されている値は SUPPORTED に含まれなくなり、カタログの読み込みで該当するファイルが見つからなくなります。設定の読み込み時に、未知の保存値を "system" に変換し、修正後の値を一度書き戻してください。こうすると、保存ファイルとコードが一致します。ユーザーは、アップデート後の最初の起動でも使用可能な言語を維持できます。

2. 誤った選択の後も言語の選択肢を読めるようにする

ユーザーが誤って日本語を選ぶと、すべての言語名が日本語で表示され、「English」を見つけにくくなります。各言語は、その言語自身の名称(endonym、自称)で表示します。こうすると、現在の UI 言語に関係なく、一覧の表記が変わりません。

export const LANGUAGE_NAMES: Record<Locale, string> = {
  en: "English",
  de: "Deutsch",
  it: "Italiano",
  ja: "日本語",
  ko: "한국어",
  "zh-Hans": "简体中文",
};

Intl.DisplayNames でも生成できますが、文字体系のバリアントに対する出力は、エンジンやバージョンによって異なります。6 個の文字列を固定の表にする方が、レビューしやすくなります。「System default」の選択肢だけは、言語名ではなく語句であるため、各ロケールの語句で翻訳します。

同じ規則を Temposhot に適用

Temposhot の Mac アプリも、言語メニューに同じ自称表記の方式を使っています。メニューは Settings の General タブにあり、七つの項目があります。各言語名はその言語で表記されます。

Temposhot の言語メニューの設定パネル。場所は Settings の General、System default を先頭とする七項目、自称のラベル、再起動後も保持される選択、英語以外の UI 翻訳は作業中。

最後の行は、この記事の読者にとって重要です。アプリは選択を保存し、直ちにロケールを切り替えます。ただし、他の六言語の翻訳が出荷されるまでは、切り替え後も一部の UI テキストが英語のまま表示されます。

メニューの順序

メニューは SUPPORTED の順序に従い、英語が先頭に来ます。固定の順序はテストしやすく、翻訳者が名称を編集しても変わりません。自称で並べ替えると、文字体系ごとの照合規則に依存した順序になり、プルリクエストでのレビューが難しくなります。

ロケールを追加するには、SUPPORTED の末尾に追加します。その後、LANGUAGE_NAMES の Record<Locale, string> 型により、新しいエントリに名前が追加されるまでビルドが失敗します。そのため、メニューに表示されるラベルがないロケールが、リゾルバーに現れることはありません。

リリース前にメニューを確認する

言語メニューには、単体テストでは検出できない小さな不具合があります。たとえば、名称が誤った文字体系で表示される、項目が欠けている、項目が保存されない、などです。リリースごとに短い手動確認を行うと、これらを見つけられます。

言語メニューの確認項目六つのチェックリスト。各言語の自称がその文字体系で表示される、System default が先頭、言語項目が六つ、切り替え時にラベルが更新される、選択がアプリの再起動後も保持される、中国語は簡体字中国語のみ表示される。

確認は、システム言語が英語でない Mac で実施します。英語のシステムでの確認では、英語の処理経路が動くことしか証明できません。

3. 英語カタログを TypeScript の契約として使う

英語を情報源とし、型を英語から導出します:

export const en = {
  "settings.language": "Language",
  "settings.language.system": "System default",
  "capture.count": "{count} screenshots",
} as const;

export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;

t("settings.language") の呼び出しはすべてコンパイラで型チェックされます。キーの誤字や、英語から削除されたキーはビルドを失敗させます。翻訳者は他のカタログを編集しますが、英語にないキーを追加することはできません。そのため、英語のファイルには UI が使用するすべてのキーが必ず含まれます。

フラットなドット区切りのキーは、型を単純に保ち、差分も読みやすくします。ネストしたオブジェクトも使えますが、再帰的な型が必要になり、不足キーの報告も追いにくくなります。

誰がどのファイルを編集するか

エンジニアは en.ts を編集します。翻訳者はロケールごとに一つのファイルを編集し、en.ts は変更しません。en.ts にだけキーを追加したプルリクエストも、他のカタログが partial 型として定義されているため、コンパイラを通過します。その後、完全性テストが各ロケールの不足キーを一覧にします。これにより、リリースで未完了の翻訳を一か所で確認できます。

キーの追加

新しいキーは四か所に影響します。以下の順序にすると、最初の手順からコンパイラが役立ちます。

メッセージキー追加の五手順。英語のキーと文言を en に追加する、コンパイラを実行して t の呼び出し箇所を型チェックする、他のカタログに翻訳を追加する、完全性テストとプレースホルダーのテストを実行する、各ロケールで画面を開きレイアウトを確認する。

最後の手順は、多くのチームが省略しがちです。ドイツ語やフィンランド語の文字列は英語より長くなることが多く、英語では収まったボタンのラベルが折り返されたり切れたりすることがあります。コンパイラとテストではこれを検出できないため、最も長い文字列の目視確認が必要です。

カタログフォルダーの構成

フォルダーには、ロケールごとのファイルと、それらを読み込むヘルパーが入ります。フラットな構成にすると import が短くなり、翻訳者がどのファイルを編集するかも明確になります。

i18n フォルダーのファイルツリー。英語、ドイツ語、イタリア語、日本語、韓国語、簡体字中国語の各カタログファイルと、index、resolve、format の各ヘルパーファイルを含む。

en.ts だけが as const オブジェクトを export します。他のカタログファイルは index.ts から Partial<Catalog> を import するため、翻訳ファイルが英語にないキーを定義することはありません。

キーの命名

各キーの先頭に、そのキーを使う画面または領域の名前を付けます。settings.language と capture.count は、コードを開かなくても文字列の場所が分かります。settings. で検索すると、その画面のすべての文字列が一覧になります。英文をキーにすることは避けます。"Language" のようなキーは、後で文言を言い換えたときに、テキストだけでなくキーも変更することになります。

メッセージキーの推奨と非推奨の一覧。キーは画面と要素で命名し、一キーに一文を対応させ、値はプレースホルダーで渡す。コードでキーを連結せず、英文をキーにしない。

意味ごとに一つのキー

キーは、テキストが表示される場所の数ではなく、文の意味に対応します。例のアプリでは、結果ヘッダー、ステータスバー、書き出しダイアログに撮影件数を表示します。三か所すべてが capture.count を使い、{count} は呼び出し側が埋めます。翻訳者は三か所の文言を一度修正すれば済みます。同じ英文を持つ別のキーがあると、時間の経過とともに文言がずれていきます。

4. 実行時のフォールバックと完全性チェックを維持する

他のカタログは翻訳ツールから JSON として届くことが多く、コンパイラでは正しさを保証できません。これらは Partial<Catalog> として型付けし、実行時にキーごとに英語へフォールバックします:

let current: Partial<Catalog> = {};

export function setCatalog(next: Partial<Catalog>) {
  current = next;
}

export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
  const template = current[key] ?? en[key];
  return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}

フォールバックにより、一部の文字列が間に合わなくても、リリースは使用可能な状態を保ちます。ただし、フォールバックは不足を隠します。そのため、出荷するロケールが不完全な場合に失敗するテストを追加します。両方が必要です。フォールバックはユーザーを守り、テストはチームが誤ってフォールバックのまま出荷することを防ぎます。以下のテストの節で、このチェックを示します。

不足している文字列の見え方

次の例では、設定画面の翻訳が完了する前に切り出されたドイツ語カタログを使います。数値は例のものであり、出荷済みビルドの値ではありません。

フォールバックの例の四つの統計値。リリース時点で de.ts に不足している 3 キー、実行時に英語で表示される 3 キー、キー名のまま表示されるキーは 0、リリースをブロックする失敗テストは 1。

ユーザーには、三つのラベルが英語で、それ以外はドイツ語で表示されます。画面に settings.language のような生のキーが表示されるのは、さらに悪い結果です。フォールバックは、英語がすべてのキーを必ず定義しているため、この事態を防ぎます。

変数が不足している場合は表示を残す

vars に値がない {name} がある場合、t 関数は出力に {name} を残します。アプリは動作を続け、レビュー担当者はスクリーンショットやテストの失敗で未入力のプレースホルダーを確認できます。黙って空文字列になるよりプレースホルダーの方が見つけやすく、画面の他の部分の描画も妨げません。

フォールバックの経路をテストする

テストで実行されないフォールバックは、誰も気づかないうちに壊れる可能性があります。空のカタログを使う短いテストで、この経路を検証します:

it("falls back to English for a missing key", () => {
  setCatalog({});
  expect(t("settings.language")).toBe("Language");
});

他のテストでは、setCatalog に実際のカタログを渡し、各テストが既知の状態から始まるようにします。

完全性テストは、ディスク上のファイルからではなく、SUPPORTED からロケールの一覧を読むべきです。ファイルは存在しても一覧にないロケールは、誤って出荷されません。一覧にあるがファイルのないロケールは、即座にテストが失敗します。

フォールバックはキー単位で動作する

フォールバックは画面全体ではなく、キーごとに適用されます。42 個の設定キーのうち 40 個を翻訳したドイツ語カタログでは、不足している二つのラベルだけが英語になり、残りの 40 個はドイツ語で表示されます。画面単位のフォールバックでは、二つの不足のために完成した 40 個の文字列まで英語になります。これはユーザーにとってより悪い結果です。

5. メッセージカタログの外で日付と数値を整形する

カタログには文を入れ、整形ルールは入れません。日付、数値、ファイルサイズ、複数形は、解決済みのロケールを使って Intl で整形します:

const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);

カタログの文字列には、{date} や {count} などのプレースホルダーを入れ、呼び出し側が値を渡す前に整形します。これにより翻訳者が日付パターンに触れずに済みます。また、どのパターンも書かなくても、米国英語のユーザーには「Mar 2, 2026」、ドイツ語のユーザーには「02.03.2026」と表示されます。複数形は言語によって数が異なるため、count === 1 ではなく Intl.PluralRules で該当するメッセージのバリアントを選びます。

複数形のカテゴリは言語ごとに異なる

Intl.PluralRules は、zero、one、two、few、many、other の最大六つのカテゴリのいずれかを返します。英語は二つ、ロシア語は四つのカテゴリを使います。count === 1 による判定は英語では正しく動きますが、ロシア語では誤ります。

複数形の選択結果の比較。英語は件数 1 で one、件数 2 と 5 で other を選ぶ。ロシア語は件数 1 で one、件数 2 で few、件数 5 で many を選ぶ。

カタログには、ロケールが使うカテゴリごとに一つのバリアントを持たせます。ヘルパーは plural.select(count) を読み、対応するキー(capture.count.one や capture.count.few など)を検索します。対応するカテゴリがない場合は other にフォールバックします。これにより、一部だけ翻訳された複数形も使用可能な状態を保ちます。

単位は値に含める

Intl.NumberFormat は桁区切りと小数点を扱うため、英語の 1.2 MB とドイツ語の 1,2 MB は同じコードパスから生成されます。単位はカタログではなく、整形された値の一部です。"{size} saved" のようなエントリは、整形済みのサイズを呼び出し側から受け取り、翻訳者は値の前後の文だけを変更します。

正確な日付の出力は実行環境に依存する

Intl.DateTimeFormat の出力は、実行環境の ICU データに従うため、Electron のバージョンによって文字列が変わることがあります。"Mar 2, 2026, 9:05 AM" を直接記述したテストは、アプリとは無関係な理由で、アップグレード後に失敗します。テストでは、同じ Intl の呼び出し結果と出力を比較し、どのマシンでもテストが通るようにタイムゾーンを固定します。

6. レンダラーの選択をメインプロセスと同期する

設定画面はレンダラーにありますが、ネイティブのメニュー、ダイアログ、トレイ、通知はメインプロセスで作成されます。メインプロセスを設定値の唯一の管理者にします:

// preload.ts
contextBridge.exposeInMainWorld("i18n", {
  setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
  onChange: (cb: (locale: Locale) => void) =>
    ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});

// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
  if (!isPreference(pref)) throw new Error("invalid language preference");
  store.set("language", pref);
  const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
  rebuildMenu(locale);
  for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
  return locale;
});

レンダラーは要求し、メインプロセスが決定・保存・配信します。後から開かれたウィンドウを含むすべてのウィンドウは、同じ解決済みロケールを読みます。IPC の入力は信頼できないため、ハンドラーは受け取った値を検証します。

設定画面はレンダラーで動くため、レンダラーの localStorage に選択を保存する方法がよく使われます。しかし、値は一つのウィンドウのストレージに残り、ウィンドウが開く前にはメインプロセスが読めません。メニューとトレイは最初のウィンドウより前に作成されるため、メインプロセスのストアにある値が必要です。

言語変更の経路

一回の言語変更は、二つのプロセスをまたぎます。以下の順序は、各手順をどちらのプロセスが担当するかを示します。

五つのボックスからなるフロー。レンダラーのピッカーが setPreference を呼び出し、preload が i18n:set チャネルで値を転送し、メインプロセスが値を検証して保存し、ネイティブメニューを再構築し、i18n:changed をすべてのウィンドウに送信する。

レンダラーは "system" を自分で解決しません。各プロセスが個別に設定を解決すると、OS の言語が変わった後に、メニューと異なる言語をウィンドウが表示する可能性があります。メインプロセスの一つのリゾルバーが、両者を一致させます。

ハンドラーで値を検証する

preload スクリプトは引数の型を定義しますが、レンダラーは ipcRenderer.invoke で任意の値を送信できます。メインプロセスは、型の元になっているのと同じリストに対して値を検証します:

const isPreference = (value: unknown): value is LanguagePreference =>
  value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));

検証に失敗した値は例外を投げ、ストアには書き込みません。二つのリストに含まれる値だけが store.set に到達します。

変更後に開かれたウィンドウ

変更後に作成されたウィンドウは、ブロードキャストを待つ必要がありません。読み込み時にメインプロセスへ問い合わせます:

ipcMain.handle("i18n:get", () =>
  resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);

レンダラーは起動時に一度 i18n:get を呼び出し、その後は i18n:changed を使います。両方の経路が同じリゾルバーを呼び出すため、新しいウィンドウと既存のウィンドウで表示が食い違うことはありません。

変更中に閉じられたウィンドウ

ブロードキャストの前に閉じられたウィンドウは BrowserWindow.getAllWindows() に含まれなくなるため、ループはそのウィンドウをスキップします。再読み込みされたウィンドウは i18n:get を再度呼び出し、現在の値を受け取ります。ブロードキャストはベストエフォートで行われ、正しい値は読み込み経路が保証します。

境界を守るテスト

  • リゾルバーの表。 ["de-CH", "en-US"](ドイツ語)、["zh-TW", "en"](英語)、[](英語)などの入力が、想定されるロケールに対応することを確認します。
  • 完全性。 出荷する各ロケールについて、en のすべてのキーが存在し、空でないことを確認します。
  • プレースホルダーの一致。 各翻訳が英語と同じプレースホルダーを使うことを確認します。{count} が削除されたり名前が変更されたりすることはありません。
  • 整形。 日付の出力は固定のタイムゾーンで確認します。どのマシンでもテストが通るようにするためです。
  • IPC ハンドラー。 無効な設定は拒否され、有効な設定はメニューの再構築とブロードキャストを起こすことを確認します。
for (const [locale, catalog] of Object.entries(catalogs)) {
  for (const key of Object.keys(en) as MessageKey[]) {
    expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
  }
}

各テストは一つの境界を検証します。次の表は、失敗したテストが示す内容を一覧にします。

テスト失敗が意味すること
リゾルバーの表確認の順序または別名表が変更された
完全性出荷するカタログに、英語で定義されたキーがない
プレースホルダーの一致翻訳が変数の名前を変えたか、削除した
整形日付や数値の出力が、ロケールではなくマシンに依存している
IPC ハンドラー無効な入力がストアに到達したか、ウィンドウに通知されなかった

カタログを変更するたびにテストスイートを実行します。リリース前だけの実行では遅すぎます。プレースホルダーの名前を変えた翻訳は、導入したプルリクエストで直すのが最も低コストで、リリースの切り出し時に見つかると最も高くつきます。

小さな i18n レイヤーにも明確な境界が必要

各判断は境界を一つ定めます。意図と解決済みロケール、名称と翻訳済みラベル、契約としての英語、フォールバックと完全性、文と整形、レンダラーとメインプロセスです。これらの境界を守れば、数百行程度の手書きのレイヤーの方が、同じことを行うように設定されたフレームワークより理解しやすく保てます。

フレームワークの導入コストに見合うのは、このレイヤーにない機能がカタログに必要になった場合です。たとえば、ソースコードからのメッセージ抽出、レビュー状況を追跡する翻訳ツール、アプリとは別に配布されるカタログの実行時読み込みなどです。そうした必要が生じるまでは、上の六つの判断で一般的な不具合を防げます。

同じ境界は、Web ビューや設定シートなど、テキストを表示する他のウィンドウにも適用されます。各ウィンドウは、メインプロセスから解決済みロケールを読み、同じカタログの型を使い、同じ完全性テストに合格します。新しい画面には新しいルールは不要で、t の呼び出しを追加するだけです。

Temposhot は、このサイトが扱う Mac アプリで、Electron アプリではなく、Xcode で構築されたネイティブの macOS アプリです。その言語設定は 7 個の選択肢を提供します。System default と、各言語をその言語自身の名称で表示する六つの言語です。

クリック操作を明確なステップごとのガイドにする

上の例のような設定変更を文書化するには、Action Capture で記録します。Temposhot は、macOS 14 以降を搭載した Apple Silicon Mac 向けに、無料でダウンロードできます。

electroni18ntypescriptengineering

繰り返し撮影するスクリーンショットを、完成したデータに仕上げます

Temposhotは、タイマーまたはクリックごとに撮影し、結果をPDF、画像、ZIP、MP4で書き出します。

関連記事