story
Shotomatic Team
7 min read

Six decisions that kept a small Electron i18n layer maintainable

A practical Electron i18n design for locale preferences, typed catalogs, fallback behavior, Intl formatting, and renderer-to-main sync.

Developer typing code on a laptop while building an Electron app

Adding Japanese to an existing Electron app forced us to answer questions that a message catalog could not solve. Which language wins when the operating system and the saved preference disagree? What happens before the renderer has told the main process about that choice? How do native dialogs avoid drifting away from the React interface?

We kept the first version deliberately small. It supports English and Japanese, uses English as the fallback, and relies on TypeScript plus a short test suite instead of a larger internationalization framework. Six early decisions made that small layer easier to reason about.

1. Store a preference, not only a locale

A saved locale such as en or ja cannot represent someone who wants the app to follow macOS. Treating the system language as an initial default has the same problem: once a concrete locale is saved, a later macOS language change can no longer take effect.

We modelled the user's choice separately from the effective locale:

type AppLocale = "en" | "ja";
type AppLocalePreference = "system" | AppLocale;

const resolveAppLocale = (preference: AppLocalePreference, languages: readonly string[]): AppLocale =>
  preference === "system" ? detectSystemLocale(languages) : preference;

Only the system preference consults navigator.languages. A manual choice always wins, even when it differs from macOS. We validate the stored value before using it and fall back to system when the value is missing, damaged, or left behind by an unsupported version.

The order of navigator.languages matters too. We select the first supported language in the user's preference list, rather than searching for Japanese anywhere and overriding an earlier English choice. If none of the entries is supported, the app uses English.

Separating the two values gives each piece of state one job. The preference records what the user asked for, while the locale records what the interface should render now.

2. Keep language choices readable after a wrong selection

The language menu always shows these labels:

  • English
  • 日本語

Those are autonyms, the names each language uses for itself. Translating both labels into the current interface language creates an avoidable recovery problem. Someone who switches to a language they cannot read may also lose the label that would help them switch back.

We still translate System default, because that option describes app behavior rather than naming a language. Tests pin the two autonyms in both catalogs so an ordinary translation edit cannot change them later.

3. Use the English catalog as a TypeScript contract

The English catalog was already the closest thing the app had to a complete copy inventory, so we made its keys the contract for every supported locale.

export const enMessages = {
  "settings.language.title": "Language",
  "settings.language.english": "English",
  "settings.language.japanese": "日本語",
} as const;

export type MessageKey = keyof typeof enMessages;

const catalogs = {
  en: enMessages,
  ja: jaMessages,
} as const satisfies Record<AppLocale, Record<MessageKey, string>>;

The catalog contract catches two common mistakes during development. A component cannot request a misspelled or nonexistent key, and a locale cannot omit a key that exists in English. Adding an English message without adding its Japanese counterpart fails type checking near the catalog change.

Typed keys do not prove that a translation is good, but they do turn structural completeness into a routine compiler check. That was enough for an app with two locales and a straightforward message shape.

4. Keep the runtime fallback and the completeness check

Compile-time completeness does not remove the need for a runtime fallback. Old local data, an unexpected build mismatch, or a future dynamic catalog can still produce a missing value. Returning the English message is safer than breaking the surrounding screen.

We did not let that fallback hide unfinished work. Type checking requires the same key set in each catalog, and tests compare representative messages, substitutions, and stable labels. Runtime resilience and development feedback solve different problems, so the layer keeps both.

Interpolation is intentionally limited to simple named values such as {count}. Plural rules, grammatical gender, and complex message selection would justify ICU MessageFormat or a dedicated library. Adding that machinery before the copy needs it would make the current system harder to inspect without solving a current problem.

5. Format dates and numbers outside the message catalog

Translated labels do not make an interface feel local when dates and numbers still follow the wrong conventions. Components receive the effective app locale and pass its matching BCP 47 tag to the platform formatters.

const getIntlLocale = (locale: AppLocale) => (locale === "ja" ? "ja-JP" : "en-US");

const label = new Intl.DateTimeFormat(getIntlLocale(locale), {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
}).format(date);

Keeping Intl.DateTimeFormat and Intl.NumberFormat outside the message function prevents the catalog from becoming a general formatting layer. It also makes the locale dependency visible at the point where a date, time, file size, or count is rendered.

Display formatting must stay separate from machine-facing output. A localized date may contain slashes or other characters that do not belong in a filename. We use predictable, file-safe patterns for exported names and reserve localized formats for text shown to people.

6. Synchronize the renderer choice with the main process

An Electron app has more than one copy surface. React renders most of our interface, but the main process owns native dialogs, capture-related prompts, and other operating-system interactions. Finishing the renderer catalog did not mean the app was fully localized.

The renderer sends both the stored preference and the effective locale through a typed IPC endpoint:

type AppLocaleSyncRequest = {
  preference: "system" | "en" | "ja";
  locale: "en" | "ja";
};

await window.electron.appLocale.syncPreference({
  preference,
  locale,
});

Sending both values keeps their meanings intact. The main process can use the effective locale immediately while still knowing whether it came from macOS or from a manual override. Before the first sync, it resolves a conservative default from Electron's app.getLocale() and falls back to English if that lookup is unavailable.

We sync when the provider mounts and again when the window regains focus. The second sync is useful during development, where a main-process reload can discard its in-memory context while the renderer stays alive. IPC failure never blocks the renderer; it only leaves native copy on its safe fallback until the next sync.

Renderer-to-main synchronization also changed our definition of done. We now review React copy, main-process copy, and macOS-owned interface text as separate surfaces. A localized settings screen cannot hide an English quit confirmation sitting outside the renderer.

Tests that protect the boundaries

The first test set focuses on state and process boundaries rather than taking snapshots of every translated screen. It verifies that:

  1. ja-JP resolves to Japanese when it is the first supported system preference.
  2. A manual language overrides the system language.
  3. Missing or unsupported stored values return to the system preference safely.
  4. Every locale satisfies the English catalog's key set.
  5. English and 日本語 stay unchanged in both interfaces.
  6. Dates and numbers use the effective app locale.
  7. The main process receives both the preference and the effective locale.

Component tests still cover important rendered states, but these smaller tests catch most regressions close to the functions that define the behavior. They are also cheap enough to run on every change.

A small i18n layer still needs clear boundaries

The useful part of this design is not the amount of code. It is the separation between preference and locale, recovery and completeness, messages and formatting, and renderer and main process.

Those boundaries give us room to replace individual pieces later. A third or fourth language may justify generated catalogs. More complex grammar may justify ICU messages. Neither change requires redefining what the saved preference means or which process owns native copy.

We built this i18n layer while adding Japanese to Shotomatic's Action Capture workflow, a Mac app we develop. The implementation examples above are simplified from the shipped application, but the state model and process boundary match the code we use.

Related posts

See more posts

Ready to automate your screenshots?

Archive books, capture content, and save hours of manual work.