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.
4 min readGuidesBy Temposhot Team
A desktop app with five to ten languages rarely needs a large i18n framework. It does need a few clear rules, because an Electron app has two processes that both show text: the renderer draws the UI, and the main process owns menus, dialogs and notifications. The six decisions below keep a small, hand-written i18n layer correct as the app and its catalogs grow.
1. Store a preference, not only a locale
Saving "de" when the user picks German looks enough until someone wants the app to follow the operating system. Store the user's intent instead, and resolve the actual locale at runtime:
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";
}
In the main process, app.getPreferredSystemLanguages() returns the ordered list from the operating system. With "system" stored, a user who changes the OS language gets the new language on the next launch without touching the app's settings.
The resolver walks the whole preference list, not only the first entry. A user with "Portuguese, then English" gets English because it is their second choice, not because it is the default. Prefix matching maps de-CH to German and zh-Hans-CN to Simplified Chinese. zh-TW matches nothing, so it falls through to the next preference instead of silently becoming Simplified Chinese.
2. Keep language choices readable after a wrong selection
If a user picks Japanese by mistake and every language name is now shown in Japanese, finding "English" again is hard. Show each language in its own language (its endonym), so the list reads the same whatever the current UI language is:
export const LANGUAGE_NAMES: Record<Locale, string> = {
en: "English",
de: "Deutsch",
it: "Italiano",
ja: "日本語",
ko: "한국어",
"zh-Hans": "简体中文",
};
Intl.DisplayNames can generate these, but its output for script variants differs between engines and versions. A fixed table of six strings is easier to review. Only the "System default" option is translated, since it is a phrase rather than a language name.
3. Use the English catalog as a TypeScript contract
Make English the source of truth and derive the type from it:
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>;
Every call to t("settings.language") is now checked by the compiler. A typo or a key removed from English fails the build. Translators work on the other catalogs, but no one can add a key that English lacks, so the English file always lists everything the UI uses.
Flat dotted keys keep the type simple and make diffs easy to read. Nested objects work too, but they need recursive types and make missing-key reports harder to follow.
4. Keep the runtime fallback and the completeness check
Other catalogs often arrive as JSON from a translation tool, so the compiler cannot vouch for them. Type them as Partial<Catalog> and fall back to English per key at runtime:
let current: Partial<Catalog> = {};
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}}`));
}
The fallback keeps a release usable when one string is late. It also hides the gap, so add a test that fails when a shipping locale is incomplete. Both are needed: the fallback protects users, and the test protects the team from shipping fallbacks by accident. The test section below shows the check.
5. Format dates and numbers outside the message catalog
Catalogs should hold sentences, not formatting rules. Dates, numbers, file sizes and plurals come from Intl, using the resolved locale:
const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);
A catalog string then contains a placeholder, such as {date} or {count}, and the caller formats the value before passing it in. This keeps translators away from date patterns, and it means a US English user sees "Mar 2, 2026" and a German user sees "02.03.2026" without anyone writing either pattern. Plural forms vary in count per language, so pick the message variant with Intl.PluralRules rather than with count === 1.
6. Synchronize the renderer choice with the main process
The settings screen lives in the renderer, but native menus, dialogs, the tray and notifications are created in the main process. Make the main process the single owner of the preference:
// 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: LanguagePreference) => {
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;
});
The renderer asks, the main process decides, persists and broadcasts. Every window, including ones opened later, reads the same resolved locale. Validate the incoming value in the handler, since IPC input is untrusted.
Tests that protect the boundaries
- Resolver table. Inputs such as
["de-CH", "en-US"](German),["zh-TW", "en"](English) and[](English) map to the expected locales. - Completeness. For each shipping locale, every key in
enexists and is not empty. - Placeholder parity. Each translation uses the same placeholders as English, so
{count}is never dropped or renamed. - Formatting. Date output is checked with a fixed time zone, so tests pass on every machine.
- IPC handler. An invalid preference is rejected, and a valid one triggers a menu rebuild and a broadcast.
for (const [locale, catalog] of Object.entries(catalogs)) {
for (const key of Object.keys(en) as MessageKey[]) {
expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
}
}
A small i18n layer still needs clear boundaries
Each decision draws a line: intent versus resolved locale, names versus translated labels, English as the contract, fallback versus completeness, sentences versus formatting, and renderer versus main. Keep those lines, and a hand-written layer of a few hundred lines stays easier to reason about than a framework configured to do the same.
Temposhot, the Mac app this site is about, is a native Swift app rather than an Electron app. Its Language setting offers 7 options: System default plus six languages shown by their own names.
Turn your clicks into a clear step-by-step guide
To document a settings change like the one above, record it with Action Capture. Temposhot is free to download for Apple Silicon Macs.
electroni18ntypescriptengineering
Turn repeatable screenshots into finished work
Temposhot captures on a timer or on every click and exports the result as a PDF, images, ZIP or MP4.
Related posts
- Automate Screenshots on Mac with the Command LineUse macOS screencapture loops for full-screen, fixed-area, and page-turn screenshots, with safe file names and troubleshooting.
- How to Automate Website Screenshots with PlaywrightUse Playwright to automate website screenshots with JavaScript. Capture one page, full pages, mobile views, and URL batches with retries.
- How to Automate Website Screenshots with PuppeteerUse Puppeteer to automate website screenshots with JavaScript. Capture one page, full-page screenshots, URL batches, and mobile viewports with a reusable script.
- Best Xnapper Alternatives for Mac in 2026Compare Xnapper alternatives for Mac: repeated capture, scrolling capture and recording, free backgrounds, cross-platform guides, and the built-in app.