Guide

How to validate i18n placeholders before release

· Written by the New Odyssey team, with AI help.

A translation can read perfectly and still break your app. The French for "{{count}} items in your cart" comes back as "articles dans votre panier", and every customer sees a cart with no number. A German translator helpfully renames {name} to {Name}, and the greeting shows the raw braces. These bugs rarely show up in review, because reviewers read the language, not the syntax.

The fix is mechanical: compare every translated string's placeholders with its source before the file is merged. This guide covers what to compare for each framework, the rules for ICU plurals and select, and how to run the check in CI.

1. Know which placeholder syntax your files use

Each i18n library has its own syntax, and a single file can mix two (an ICU message inside a JSON value, an HTML tag inside an Android string).

Library or format Placeholder Example
i18next {{name}}, nesting $t(key) Hello, {{name}}
react-intl, FormatJS, vue-i18n, Flutter .arb ICU {name}, {0} Hello, {name}
Android, iOS, gettext (C, PHP) printf %s, %d, %1$s, %@ %1$s sent you %2$d files
Rails %{name} Hello, %{name}
Python gettext %(name)s Hello, %(name)s
react-i18next Trans, HTML <0>…</0>, …,
Read the <0>terms</0>

Write down which ones your project uses. A checker that only knows {name} will miss a dropped %1$s.

2. The rules every translated string must pass

For each key, extract the placeholders from the source and from the translation, then check:

  1. Nothing missing. Every placeholder in the source appears in the translation.
  2. Nothing added. The translation has no placeholder the source doesn't. An extra {{price}} renders as empty or as raw text.
  3. Nothing renamed. {name} and {nom} are different variables. Compare names exactly, including case.
  4. Tags match. Every opening tag has its closing tag, with the same names or numbers as the source. react-i18next maps <0> and <1> to components by number, so <1> in the source must stay <1>.
  5. Unnamed printf order is kept. "%s of %d" can't become "%d of %s". Positional forms (%1$s, %2$d) may be reordered freely.
  6. The string still parses. For ICU messages, an unbalanced brace or a stray apostrophe makes the whole message fail at runtime.

Counting matters as well as presence: if the source uses {{count}} twice, a translation with one {{count}} may be fine (languages differ), but a translation with none is not.

3. ICU plural and select, the rules people get wrong

ICU messages carry structure, not just slots:

{count, plural, one {# file} other {# files}}
{role, select, admin {Manage team} other {View team}}
  • Keep the argument and its type. {count, plural, …} must stay a plural on count. Turning it into plain text, or into a select, breaks it.
  • Always keep other. It's required by the ICU syntax and is the fallback for every number that doesn't match a category.
  • Plural categories depend on the target language, not the source. English needs one and other. Polish and Russian need one, few, many and other. Japanese, Chinese and Korean need only other. Arabic uses zero, one, two, few, many and other. A Polish translation with only one and other is valid syntax but grammatically wrong for 2-4 and 5+ items, so flag it.
  • Never translate select options. In {role, select, admin {…} other {…}}, admin is a value your code passes. Translate the text in the braces only. If the translation's options differ from the source's, block it.
  • # is a placeholder too. Inside a plural branch, # prints the number. Dropping it from the other branch usually loses the count.

The same rules apply to plural groups stored as separate keys, such as i18next's items_one and items_other, Android's and gettext's msgstr[0], msgstr[1]: the target file needs one entry per category of the target language.

4. Check length while you're there

Placeholder checks catch crashes; length checks catch layout bugs. Short strings grow the most in translation: a 4-character English button can double or triple in German or Finnish, while long sentences grow perhaps 20 to 40 percent. Two cheap checks:

  • A ratio against the source, with a higher allowance for short strings, flags "likely too long" for a person to look at.
  • A hard character limit on strings where you know the space (buttons, tab labels, push notification titles, App Store fields). Over the limit should fail the build.

5. Run it in CI, not in review

A check that depends on someone remembering to run it won't run the week it matters. Add it to the pipeline that merges translations:

# .github/workflows/i18n.yml (sketch)
- name: Validate translations
  run: node scripts/check-placeholders.mjs src/locales/en.json src/locales/*.json

The script needs to do four things: load the source file, flatten keys (nested JSON to dotted paths), extract placeholders with one regular expression per syntax you use, and exit non-zero with a list of key, language and problem when any rule fails. For ICU messages, use a real parser such as @formatjs/icu-messageformat-parser rather than regular expressions, so nested plurals and escaped braces are handled.

A useful failure message names the key, the language and the fix:

fr  checkout.items_other  missing {{count}}
de  profile.greeting      {Name} isn't in the source (did you mean {name}?)
pl  cart.files            plural is missing: few, many

6. Don't let tools fix placeholders silently

It's tempting to auto-repair: re-insert a missing {{count}} at the end, or rename {Name} back to {name}. Resist it for anything but exact case fixes you can prove. A re-inserted placeholder in the wrong place produces a sentence that is grammatical nonsense, and nobody reads it again because the check passed. Send broken strings back to a person, show them exactly what's wrong, and keep the broken string out of the release until it's fixed.

A short checklist

  • List every placeholder syntax your files use.
  • Compare placeholders per key: missing, added, renamed, tags, printf order.
  • Parse ICU messages; keep argument types, other, select options and #.
  • Require the target language's plural categories.
  • Flag likely-too-long strings; fail on hard limits.
  • Run it in CI on every translation change, and block the merge on failure.

Parla runs these checks on every translation, whoever wrote it, and won't export a string that fails them. You can try it on one of your own files on the sample page, with no account.

Questions

What is a placeholder in a translation file?

A placeholder is a slot your code fills at runtime, such as {name}, {{count}}, %s or %{name}. The translation must contain the same placeholders as the source, spelled exactly the same, or the app shows raw braces, the wrong value, or crashes.

Can a translator reorder placeholders?

Named placeholders ({name}, {{name}}, %{name}) and positional ones (%1$s, %2$d) can move anywhere the grammar needs. Unnamed printf placeholders (%s, %d) are filled in order, so they must stay in the source's order.

Should ICU select options be translated?

No. In {gender, select, male {...} female {...} other {...}} the words male, female and other are values your code passes in. Translate the text inside the braces, never the option names.

Questions buyers ask

How do I validate i18n placeholders before release?

Compare each translation's placeholders with its source: nothing missing, added or renamed, tags matched, unnamed printf arguments in order, and ICU messages that parse with the same plural and select structure. Require the target language's plural categories, and run the check in CI so a failure blocks the merge.

Part of the New Odyssey suite

Six tools for the work around your clients, from being found to packaging what you know. Each one works on its own.

  1. KleosGets you found
  2. OndaAnswers the inquiry
  3. BookaraQualifies and books
  4. AccordSends the proposal
  5. TallynOnboards the client
  6. AulaPackages your expertise