/i18n
Take an app from one language to many, and keep it there. Written after
shipping 36 languages in an iOS app; the traps below each cost real time and
none of them announce themselves — a missed string looks perfect in English.
The order of work
- Humanize the source language first. Translating is per-language cost:
fixing an English sentence after the fact means re-translating it in every
language.
/humanize on the long copy, then translate.
- Extract, don't hunt. Get the platform's extractor to produce the key
list. A grep for quoted strings finds prose and misses everything typed
wrong (see the type rule below).
- Translate in batches, machine-first, with a validator that refuses a
batch dropping a format specifier or a plural category.
- Look at it running, in a right-to-left language and in a long-word
language (German, Finnish). A language that is merely declared is not a
language that works.
The type rule — the one that decides everything
Anything a person reads must be typed as the platform's localizable type,
not as a plain string.
| Platform |
Right |
Wrong, and silent |
| SwiftUI |
LocalizedStringKey, LocalizedStringResource |
let title: String |
| React / Next |
t("key") from the i18n hook |
a bare JSX literal in a prop |
| Android Compose |
stringResource(R.string.x) |
a String constant |
The extractor only sees literals in positions typed as localizable. A view
property declared let title: String takes its literal from the call site,
where nothing is looking — the control is never translated and English still
looks perfect. One pass found the whole bottom tab bar, four gallery filters,
four calendar chips, the settings list and every icon button's screen-reader
name, all invisible until somebody switched language.
Put it in the pre-commit hook. A lint rule on the naming that means "this is
shown" (title, label, note, blurb, caption, placeholder) typed as
a plain string catches it while it is one line, not one release.
Three ways strings escape the extractor
- An enum's
rawValue.capitalized is not a label. The raw value is the
switch's identity; drawing chips from it makes them untranslatable.
- Never compare against a shown word.
if label == "Auto" matches one
language in thirty-six. Compare by case or by index.
- An accessibility identifier is not a title. Test scripts find rows by
settings.film; if that string follows the language, the whole automation
suite breaks in every language but one. Pass the id separately.
Counts, dates and money
- Counted nouns go through the plural system, never a
== 1 ternary.
CLDR categories differ: Japanese 1, English 2, Romanian and Hebrew 3,
Russian and Polish 4, Arabic 6 (zero/one/two/few/many/other). Only
other is universally required; write the table from CLDR, not memory.
- Dates are built from a template, never glued. Order, separators and
month case all move:
setLocalizedDateFormatFromTemplate (Apple),
Intl.DateTimeFormat (web), DateTimeFormatter.ofLocalizedDate (JVM).
"March 5" and "5 марта" come from one call.
- A fixed format needs a fixed locale. A formatter with
dateFormat = "yyyy-MM-dd" for a filename or an id follows the phone's
calendar and digits — 2569 under Buddhist, ٢٠٢٦ under Arabic-Indic. Apple:
en_US_POSIX. Anywhere: an explicit invariant locale.
- Durations and numbers have a formatter too.
Duration.UnitsFormatStyle,
Intl.NumberFormat, NumberFormat — hand-written \(m)m \(s)s is a bug
in every language including English.
- Never interpolate a year into a translatable string: it gets grouped
("2 026"). Wrap it as a plain string first.
- A cached formatter keeps the language it was born in. A
static let formatter built once served German durations inside an Arabic
UI. Formatters that a person reads get built per use, or rebuilt on change.
Runtime language switching
If the app offers its own language picker rather than deferring to the OS,
three things are true and each has bitten:
- The system resolves the bundle before your code runs. Writing the
language into the OS's own key (
AppleLanguages, LANG, the cookie) is what
makes it survive a relaunch, but on its own it is a change you see next
launch. A picker that says "restart to apply" is a picker people press twice.
- The override must cover every lookup path. Measured on iOS: swapping the
main bundle's class fixed SwiftUI's
Text("key") and did nothing for
String(localized:), which resolves the bundle by identity. Half the app
translated and half did not. Whatever mechanism you pick, prove it on
both paths before believing it.
- "System" must mean the system. Once the app writes its own language key,
every "what does this phone speak" API answers with that choice — so going
back to System does nothing. Read the platform's language list only after
clearing your own key, and negotiate it against the locales you actually
ship.
Log one line at launch naming what was chosen and what it resolved to. From
outside the app there is no other way to tell "the phone is German" from "the
picker is stuck on German".
Machine translation, honestly
Machine translation is the right default for shipping: it is far better than
English-only, and a native pass can follow per language as users arrive. Two
rules keep it from embarrassing you:
- Never machine-translate a legal or a money string. Refunds, consent,
subscription terms and permission prompts get a human, or stay in English.
- Say which languages had a native reader. Keep it in the doc, so nobody
later assumes all of them did.
Validate every batch before it lands:
- every format specifier in the source appears in the translation;
- plural categories belong to that language;
- brand names, units and code identifiers are untouched;
- nothing got "helpfully" expanded past the space it has.
Testing
- The suite runs in one fixed language. If the test host is the app, a
language somebody picked by hand on that machine becomes the language the
suite compares against — nineteen tests once failed that way, on a green
build.
- Assert against the built bundle, not the source catalogue. A complete
catalogue that fell out of the build config produces exactly the same
English screen as a missing translation, and only the bundle tells them
apart.
- Check pluralization for one language with more forms than English — it
catches a whole class of "translated the singular only".
- Screenshot the longest language and an RTL one. German and Finnish find
the truncation; Arabic and Hebrew find the layout that never mirrored.
- Switch to RTL the platform's way, not the app's. An in-app picker moves
the strings and usually not the writing direction: the OS reads its own
language setting before your code runs, so mirroring arrives one launch
late. Measured once as Arabic text in a left-to-right layout followed by
English text in a mirrored one — two screenshots, each looking like a bug in
the other language. On iOS that means launching with
-AppleLanguages;
whatever the platform, find one element whose side you know (a tab at the
far edge) and check it before reading anything else.
Spoken numbers
Anything a screen reader says aloud is where hand-built grammar shows first,
because it is the one string nobody looks at. "%d minutes %d seconds" is a
sentence shape only English has, and as a translatable key it needs plural
variations for both numbers in every language that has them — Arabic would
need thirty-six combinations.
Hand it to the platform's duration formatter instead (Duration.UnitsFormatStyle,
Intl.RelativeTimeFormat / Intl.NumberFormat, java.time + ICU) with the
chosen locale. Every language gets its own agreement, and there is no key to
translate at all.
The store page is a second catalogue
Translating the app does not translate the listing, and the two sets do not
match. The App Store carries 50 locales and Google Play about 80; an app that
ships 35 languages will find several of them missing (Bashkir, Tatar, Kazakh
and Filipino are not in Apple's list at all), while the store offers markets
the app has no notion of — en-GB, es-MX, pt-PT, fr-CA. Decide which to
fill deliberately; do not let a loop over the app's languages decide it.
Two rules, both learned the expensive way:
- Check the source-language listing against the code before translating.
A description that promises a feature which is switched off becomes the same
untruth in thirty-one languages, and the correction costs thirty-one edits.
- Keep the metadata in the repo, not in the web form.
asc metadata pull
(Apple) or the Play Publishing API give canonical per-locale files that
diff, review and re-push. Field limits are per locale and unforgiving:
App Store description 4000, keywords 100, subtitle 30, promo text 170.
Keywords are researched in the storefront, never translated
Translating the English keyword list into 31 languages produces 31 lists of
words nobody types. Query the store itself — Apple's public search needs no
auth (asc apps public search --term "…" --country ru), Play's listings are
readable from the web — and look at what actually ranks for the local term.
What that turned up in one pass, none of it guessable from a dictionary:
- Compound words split differently per language. Russian and Ukrainian
competitors all write "видео редактор" as two words; a single compound in
the field is a different token and misses the split query entirely. Since
the subtitle already carried one half, the field only had to carry the other.
- In several markets the top results are English-named apps even when the
query is local — measured in Thailand, India, Malaysia, Indonesia, Poland,
Ukraine, Vietnam and Saudi Arabia. Those locales want the English terms
beside the local ones, not instead of them.
- In others the local word wins, and it is the competition's exact word —
German ranks
videobearbeitung, Korean 편집기, Japanese エディター. And
where a domestic platform owns the category (Douyin and Kuaishou in China),
the head term is not a plan; spend the field on the niche phrases instead.
Deliverables
A finished localization pass leaves behind:
- the catalogue, complete, with a script that reports what is missing;
- a merge tool that validates batches, so a bad one cannot land quietly;
- a lint or hook rule for the type mistake;
- a doc naming the language set, why those, which had native readers, and how
to add the next one;
- screenshots of at least one RTL and one long-word language.
1---2name: solo-i18n3description: Use when "add languages", "translate the app", "localization", "i18n", "make it multilingual", or when UI strings are hardcoded and a second language is coming.4license: MIT5---67# /i18n89Take an app from one language to many, and keep it there. Written after10shipping 36 languages in an iOS app; the traps below each cost real time and11none of them announce themselves — a missed string looks perfect in English.1213## The order of work14151. **Humanize the source language first.** Translating is per-language cost:16 fixing an English sentence after the fact means re-translating it in every17 language. `/humanize` on the long copy, then translate.182. **Extract, don't hunt.** Get the platform's extractor to produce the key19 list. A grep for quoted strings finds prose and misses everything typed20 wrong (see the type rule below).213. **Translate in batches**, machine-first, with a validator that refuses a22 batch dropping a format specifier or a plural category.234. **Look at it running**, in a right-to-left language and in a long-word24 language (German, Finnish). A language that is merely declared is not a25 language that works.2627## The type rule — the one that decides everything2829**Anything a person reads must be typed as the platform's localizable type,30not as a plain string.**3132| Platform | Right | Wrong, and silent |33|---|---|---|34| SwiftUI | `LocalizedStringKey`, `LocalizedStringResource` | `let title: String` |35| React / Next | `t("key")` from the i18n hook | a bare JSX literal in a prop |36| Android Compose | `stringResource(R.string.x)` | a `String` constant |3738The extractor only sees literals in positions typed as localizable. A view39property declared `let title: String` takes its literal from the call site,40where nothing is looking — the control is never translated and English still41looks perfect. One pass found the whole bottom tab bar, four gallery filters,42four calendar chips, the settings list and every icon button's screen-reader43name, all invisible until somebody switched language.4445Put it in the pre-commit hook. A lint rule on the naming that means "this is46shown" (`title`, `label`, `note`, `blurb`, `caption`, `placeholder`) typed as47a plain string catches it while it is one line, not one release.4849## Three ways strings escape the extractor5051- **An enum's `rawValue.capitalized` is not a label.** The raw value is the52 switch's identity; drawing chips from it makes them untranslatable.53- **Never compare against a shown word.** `if label == "Auto"` matches one54 language in thirty-six. Compare by case or by index.55- **An accessibility *identifier* is not a title.** Test scripts find rows by56 `settings.film`; if that string follows the language, the whole automation57 suite breaks in every language but one. Pass the id separately.5859## Counts, dates and money6061- **Counted nouns go through the plural system, never a `== 1` ternary.**62 CLDR categories differ: Japanese 1, English 2, Romanian and Hebrew 3,63 Russian and Polish 4, **Arabic 6** (zero/one/two/few/many/other). Only64 `other` is universally required; write the table from CLDR, not memory.65- **Dates are built from a template, never glued.** Order, separators and66 month case all move: `setLocalizedDateFormatFromTemplate` (Apple),67 `Intl.DateTimeFormat` (web), `DateTimeFormatter.ofLocalizedDate` (JVM).68 "March 5" and "5 марта" come from one call.69- **A fixed format needs a fixed locale.** A formatter with70 `dateFormat = "yyyy-MM-dd"` for a filename or an id follows the phone's71 calendar and digits — 2569 under Buddhist, ٢٠٢٦ under Arabic-Indic. Apple:72 `en_US_POSIX`. Anywhere: an explicit invariant locale.73- **Durations and numbers have a formatter too.** `Duration.UnitsFormatStyle`,74 `Intl.NumberFormat`, `NumberFormat` — hand-written `\(m)m \(s)s` is a bug75 in every language including English.76- **Never interpolate a year into a translatable string**: it gets grouped77 ("2 026"). Wrap it as a plain string first.78- **A cached formatter keeps the language it was born in.** A79 `static let` formatter built once served German durations inside an Arabic80 UI. Formatters that a person reads get built per use, or rebuilt on change.8182## Runtime language switching8384If the app offers its own language picker rather than deferring to the OS,85three things are true and each has bitten:8687- **The system resolves the bundle before your code runs.** Writing the88 language into the OS's own key (`AppleLanguages`, `LANG`, the cookie) is what89 makes it survive a relaunch, but on its own it is a change you see *next*90 launch. A picker that says "restart to apply" is a picker people press twice.91- **The override must cover every lookup path.** Measured on iOS: swapping the92 main bundle's class fixed SwiftUI's `Text("key")` and did nothing for93 `String(localized:)`, which resolves the bundle by identity. Half the app94 translated and half did not. Whatever mechanism you pick, prove it on95 *both* paths before believing it.96- **"System" must mean the system.** Once the app writes its own language key,97 every "what does this phone speak" API answers with that choice — so going98 back to System does nothing. Read the platform's language list only after99 clearing your own key, and negotiate it against the locales you actually100 ship.101102Log one line at launch naming what was chosen and what it resolved to. From103outside the app there is no other way to tell "the phone is German" from "the104picker is stuck on German".105106## Machine translation, honestly107108Machine translation is the right default for shipping: it is far better than109English-only, and a native pass can follow per language as users arrive. Two110rules keep it from embarrassing you:111112- **Never machine-translate a legal or a money string.** Refunds, consent,113 subscription terms and permission prompts get a human, or stay in English.114- **Say which languages had a native reader.** Keep it in the doc, so nobody115 later assumes all of them did.116117Validate every batch before it lands:118119- every format specifier in the source appears in the translation;120- plural categories belong to that language;121- brand names, units and code identifiers are untouched;122- nothing got "helpfully" expanded past the space it has.123124## Testing125126- **The suite runs in one fixed language.** If the test host is the app, a127 language somebody picked by hand on that machine becomes the language the128 suite compares against — nineteen tests once failed that way, on a green129 build.130- **Assert against the built bundle, not the source catalogue.** A complete131 catalogue that fell out of the build config produces exactly the same132 English screen as a missing translation, and only the bundle tells them133 apart.134- **Check pluralization for one language with more forms than English** — it135 catches a whole class of "translated the singular only".136- **Screenshot the longest language and an RTL one.** German and Finnish find137 the truncation; Arabic and Hebrew find the layout that never mirrored.138- **Switch to RTL the platform's way, not the app's.** An in-app picker moves139 the strings and usually not the writing direction: the OS reads its own140 language setting before your code runs, so mirroring arrives one launch141 late. Measured once as Arabic text in a left-to-right layout followed by142 English text in a mirrored one — two screenshots, each looking like a bug in143 the other language. On iOS that means launching with `-AppleLanguages`;144 whatever the platform, find one element whose side you know (a tab at the145 far edge) and check it before reading anything else.146147## Spoken numbers148149Anything a screen reader says aloud is where hand-built grammar shows first,150because it is the one string nobody looks at. `"%d minutes %d seconds"` is a151sentence shape only English has, and as a translatable key it needs plural152variations for *both* numbers in every language that has them — Arabic would153need thirty-six combinations.154155Hand it to the platform's duration formatter instead (`Duration.UnitsFormatStyle`,156`Intl.RelativeTimeFormat` / `Intl.NumberFormat`, `java.time` + ICU) with the157chosen locale. Every language gets its own agreement, and there is no key to158translate at all.159160## The store page is a second catalogue161162Translating the app does not translate the listing, and the two sets do not163match. The App Store carries 50 locales and Google Play about 80; an app that164ships 35 languages will find several of them missing (Bashkir, Tatar, Kazakh165and Filipino are not in Apple's list at all), while the store offers markets166the app has no notion of — `en-GB`, `es-MX`, `pt-PT`, `fr-CA`. Decide which to167fill deliberately; do not let a loop over the app's languages decide it.168169Two rules, both learned the expensive way:170171- **Check the source-language listing against the code before translating.**172 A description that promises a feature which is switched off becomes the same173 untruth in thirty-one languages, and the correction costs thirty-one edits.174- **Keep the metadata in the repo, not in the web form.** `asc metadata pull`175 (Apple) or the Play Publishing API give canonical per-locale files that176 diff, review and re-push. Field limits are per locale and unforgiving:177 App Store description 4000, keywords 100, subtitle 30, promo text 170.178179### Keywords are researched in the storefront, never translated180181Translating the English keyword list into 31 languages produces 31 lists of182words nobody types. Query the store itself — Apple's public search needs no183auth (`asc apps public search --term "…" --country ru`), Play's listings are184readable from the web — and look at what actually ranks for the local term.185186What that turned up in one pass, none of it guessable from a dictionary:187188- **Compound words split differently per language.** Russian and Ukrainian189 competitors all write "видео редактор" as two words; a single compound in190 the field is a different token and misses the split query entirely. Since191 the subtitle already carried one half, the field only had to carry the other.192- **In several markets the top results are English-named apps even when the193 query is local** — measured in Thailand, India, Malaysia, Indonesia, Poland,194 Ukraine, Vietnam and Saudi Arabia. Those locales want the English terms195 *beside* the local ones, not instead of them.196- **In others the local word wins, and it is the competition's exact word** —197 German ranks `videobearbeitung`, Korean `편집기`, Japanese `エディター`. And198 where a domestic platform owns the category (Douyin and Kuaishou in China),199 the head term is not a plan; spend the field on the niche phrases instead.200201## Deliverables202203A finished localization pass leaves behind:204205- the catalogue, complete, with a script that reports what is missing;206- a merge tool that validates batches, so a bad one cannot land quietly;207- a lint or hook rule for the type mistake;208- a doc naming the language set, why those, which had native readers, and how209 to add the next one;210- screenshots of at least one RTL and one long-word language.