If your app is Japanese-first, an AI can turn ja.json into en.json in seconds. What it can easily miss is the _one plural keys English needs, because the Japanese file never had them. This post shows why, what i18next actually renders when they're missing, a prompt that asks for them, and a small Python checker that catches the rest.
Disclosure: this article was drafted and edited with AI tools (#abotwrotethis). The checker and the i18next demo were run on October 21, 2026 (Python 3.13.5, Node 20.19.2, i18next 26.5.3) and the output below is real. The English JSON files used in the test were written for this article to reproduce common mistakes; the prompt was not run in ChatGPT, Claude or Gemini, so no chatbot output is shown.
Pasting a locale file into a chatbot can get the text itself mostly right. The problems are structural, and they're easy to miss because the result still looks like JSON:
-
The reply isn't JSON. "Sure! Here is the translation:" plus a
```jsonfence, andJSON.parsefails. - Keys go missing. Long files can get trimmed, or a key the model found odd can be skipped.
-
Placeholders change.
{{fileName}}becomes{{filename}},{{- link}}loses its dash,$t(nav.home)gets "translated" into plain text. - Plural keys don't match English. This one is specific to translating from Japanese.
Number 4 is the easiest one to miss, so let's start there.
The plural trap: Japanese has only other
i18next's plural keys follow the Unicode CLDR plural categories. The i18next docs say "These plurals are streamlined with the one used in the Intl API", and the suffixes are the CLDR category names: _zero, _one, _two, _few, _many, _other.
In CLDR's Language Plural Rules chart, Japanese (ja) has a single cardinal category, other ("no plural differences" for ranges). English (en) has two: one (for 1) and other. Node's Intl agrees:
node -e "for (const l of ['ja','en']) console.log(l, new Intl.PluralRules(l).resolvedOptions().pluralCategories, new Intl.PluralRules(l, {type: 'ordinal'}).resolvedOptions().pluralCategories)"
ja [ 'other' ] [ 'other' ]
en [ 'one', 'other' ] [ 'few', 'one', 'two', 'other' ]
(First array: cardinal categories. Second: ordinal, as in 1st/2nd/3rd.)
So a correct ja.json has only _other keys:
"notifications_other": "{{count}}件の新しい通知"
and a correct en.json needs both:
"notifications_one": "{{count}} new notification",
"notifications_other": "{{count}} new notifications"
An AI translating key by key sees one key and is likely to return one key. Nothing in the source tells it English needs a second one.
What i18next does when _one is missing
Here's the Japanese source file used for the test (written by our team for this article):
{
"nav": {
"home": "ホーム",
"settings": "設定"
},
"greeting": "こんにちは、{{name}}さん!",
"welcomeBack": "おかえりなさい、{{name}}さん。$t(nav.home)に戻ります。",
"cart": {
"items_other": "カートに{{count}}点の商品があります",
"empty": "カートは空です"
},
"notifications_other": "{{count}}件の新しい通知",
"trialDays_other": "無料体験はあと{{count}}日です",
"deleteConfirm": "「{{fileName}}」を削除しますか?この操作は取り消せません。",
"lastLogin": "最終ログイン:{{date, datetime}}",
"helpLink": "くわしくは{{- link}}をご覧ください",
"rank_ordinal_other": "{{count}}位"
}
And an English file with the mistakes described above, written to reproduce them:
{
"nav": {
"home": "Home",
"settings": "設定"
},
"greeting": "Hello, {{name}}!",
"welcomeBack": "Welcome back, {{name}}. Returning to Home.",
"cart": {
"items_other": "You have {{count}} items in your cart",
"empty": "Your cart is empty"
},
"notifications_other": "{{count}} new notifications",
"trialDays_other": "{{count}} days left in your free trial",
"deleteConfirm": "Delete \"{{filename}}\"? This can't be undone.",
"helpLink": "See {{link}} for details",
"rank_ordinal_other": "{{count}}th place"
}
This script loads it into real i18next, with Japanese as the fallback language and without a fallback:
// What i18next itself does with the two English files.
import i18next from 'i18next';
import { readFileSync } from 'node:fs';
const load = (f) => JSON.parse(readFileSync(f, 'utf8'));
for (const file of ['en_broken.json', 'en_fixed.json']) {
for (const fallbackLng of ['ja', false]) {
const i18n = i18next.createInstance();
await i18n.init({
lng: 'en',
fallbackLng,
interpolation: { escapeValue: false }, // as in React setups; keeps "/" readable here
resources: { en: { translation: load(file) }, ja: { translation: load('ja.json') } },
});
console.log(`--- ${file}, fallbackLng: ${fallbackLng}`);
for (const count of [1, 5]) {
console.log(` t('notifications', { count: ${count} }) ->`, i18n.t('notifications', { count }));
}
console.log(` t('lastLogin') ->`, i18n.t('lastLogin', { date: new Date(2026, 9, 21) }));
for (const count of [1, 2, 11]) {
console.log(` t('rank', { count: ${count}, ordinal: true }) ->`, i18n.t('rank', { count, ordinal: true }));
}
}
}
Real output (the en_broken.json part):
--- en_broken.json, fallbackLng: ja
t('notifications', { count: 1 }) -> 1件の新しい通知
t('notifications', { count: 5 }) -> 5 new notifications
t('lastLogin') -> 最終ログイン:10/21/2026
t('rank', { count: 1, ordinal: true }) -> 1位
t('rank', { count: 2, ordinal: true }) -> 2位
t('rank', { count: 11, ordinal: true }) -> 11th place
--- en_broken.json, fallbackLng: false
t('notifications', { count: 1 }) -> notifications
t('notifications', { count: 5 }) -> 5 new notifications
t('lastLogin') -> lastLogin
t('rank', { count: 1, ordinal: true }) -> rank
t('rank', { count: 2, ordinal: true }) -> rank
t('rank', { count: 11, ordinal: true }) -> 11th place
With fallbackLng: 'ja', an English user with exactly one notification sees Japanese: 1件の新しい通知. i18next looks for notifications_one in English, doesn't find it, and falls back to Japanese, where count: 1 resolves to notifications_other. Without a fallback, the user sees the raw key notifications. For 5 notifications everything looks fine, which is why this slips through testing. Ordinals behave the same way: 1 and 2 break (3 would too), and 11 works because English uses other for it.
The same script on the corrected file:
--- en_fixed.json, fallbackLng: ja
t('notifications', { count: 1 }) -> 1 new notification
t('notifications', { count: 5 }) -> 5 new notifications
t('lastLogin') -> Last login: 10/21/2026
t('rank', { count: 1, ordinal: true }) -> 1st place
t('rank', { count: 2, ordinal: true }) -> 2nd place
t('rank', { count: 11, ordinal: true }) -> 11th place
--- en_fixed.json, fallbackLng: false
t('notifications', { count: 1 }) -> 1 new notification
t('notifications', { count: 5 }) -> 5 new notifications
t('lastLogin') -> Last login: 10/21/2026
t('rank', { count: 1, ordinal: true }) -> 1st place
t('rank', { count: 2, ordinal: true }) -> 2nd place
t('rank', { count: 11, ordinal: true }) -> 11th place
(interpolation: { escapeValue: false } is only there so the date prints as 10/21/2026 instead of an HTML-escaped string. i18next escapes interpolated values by default to mitigate XSS, so keep the default unless your framework escapes for you. React DOM escapes values embedded in JSX, which is why the react-i18next setup turns it off.)
Two more details from the i18next Plurals page worth knowing: "The variable name must be count", and "Since i18next v24 there is no fallback: without Intl only English-style _one/_other forms resolve."
The prompt
Paste this, then the contents of ja.json. For long files, send one top-level section per message and merge the results.
Translate the values of this i18next JSON file (v4 format) from Japanese
to natural English for app UI.
Rules:
1. Return only the JSON. No introduction, no explanation, no Markdown code fence.
2. Keep every key and the nesting exactly as they are. Translate values only.
Never drop a key.
3. Copy every {{...}} placeholder and every $t(...) reference exactly,
including spelling, case, spaces and a leading "-" (as in "{{- link}}").
Never translate what's inside them.
4. Japanese has only the plural form "_other". For every key ending in
"_other" (not "_ordinal_other"), also add the matching "_one" key
with the singular English form, placed just before it.
5. For every key ending in "_ordinal_other", add "_ordinal_one",
"_ordinal_two" and "_ordinal_few" (1st, 2nd, 3rd) as well.
6. Keep UI text short. Don't add punctuation or emoji the Japanese
doesn't have.
Rules 4 and 5 are the JA→EN-specific part. Even with them, check the output; that's what the script is for.
The checker (Python, stdlib only)
It compares the source and the translated file and reports:
- invalid JSON, with a hint if it looks like a chat reply,
- missing keys, including the plural forms the target language needs (
--forms,--ordinal-forms; English by default), - any key whose
{{...}}or$t(...)tokens differ from the source (exact match, so{{filename}}vs{{fileName}}is an error), - values that still contain Japanese characters,
- extra keys not in the source (
_zerois allowed, since i18next supports it as an optional key forcount: 0).
A _one value may leave out {{count}} ("One new notification"); every other placeholder must stay.
#!/usr/bin/env python3
"""Check an AI-translated i18next JSON file against its source (stdlib only).
usage: python3 i18n_check.py SOURCE.json TARGET.json [--forms one,other]
[--ordinal-forms one,two,few,other]
--forms / --ordinal-forms = the CLDR plural categories the TARGET language needs.
Defaults are English: cardinal one,other; ordinal one,two,few,other.
"""
import json
import re
import sys
PLURAL = re.compile(r"^(?P<base>.+?)_(?P<ord>ordinal_)?(?P<cat>zero|one|two|few|many|other)$")
TOKEN = re.compile(r"\{\{.*?\}\}|\$t\([^)]*\)") # {{...}} interpolation and $t(...) nesting
JAPANESE = re.compile(r"[\u3040-\u30ff\u4e00-\u9fff]")
def load(path):
text = open(path, encoding="utf-8").read()
try:
return json.loads(text)
except json.JSONDecodeError as e:
hint = ""
if "```
" in text or not text.lstrip().startswith(("{", "[")):
hint = " (looks like a chat reply: remove the intro line and the
``` fence)"
sys.exit(f"ERROR {path}: not valid JSON at line {e.lineno}, col {e.colno}: {e.msg}{hint}")
def flatten(obj, prefix=""):
"""{"cart": {"empty": "..."}} -> {"cart.empty": "..."} (i18next's default "." separator)."""
out = {}
items = obj.items() if isinstance(obj, dict) else enumerate(obj)
for k, v in items:
key = f"{prefix}{k}"
if isinstance(v, (dict, list)):
out.update(flatten(v, key + "."))
else:
out[key] = v
return out
def tokens(value):
return sorted(TOKEN.findall(value)) if isinstance(value, str) else []
def expected_keys(src, forms, ordinal_forms):
"""Every key the target must have -> the source key to compare it with."""
exp, groups = {}, {}
for key in src:
m = PLURAL.match(key)
if m:
groups.setdefault((m["base"], m["ord"] or ""), []).append(key)
else:
exp[key] = key
for (base, ordinal), keys in groups.items():
ref = next((k for k in keys if k.endswith("_other")), keys[0])
cats = ordinal_forms if ordinal else forms
for cat in cats:
exp[f"{base}_{ordinal}{cat}"] = ref
return exp
def main():
args = sys.argv[1:]
opts = {"--forms": ["one", "other"], "--ordinal-forms": ["one", "two", "few", "other"]}
for name in opts:
if name in args:
i = args.index(name)
opts[name] = args[i + 1].split(",")
del args[i:i + 2]
if len(args) != 2:
sys.exit(__doc__)
src, tgt = flatten(load(args[0])), flatten(load(args[1]))
exp = expected_keys(src, opts["--forms"], opts["--ordinal-forms"])
errors = warnings = 0
for key, ref in exp.items():
if key not in tgt:
extra = ""
if PLURAL.match(key) and ref != key:
extra = f" (plural form the target language needs; source only has {ref})"
print(f"ERROR missing key: {key}{extra}")
errors += 1
continue
value = tgt[key]
if type(value) is not type(src[ref]):
print(f"ERROR {key}: value type changed ({type(src[ref]).__name__} -> {type(value).__name__})")
errors += 1
continue
want, got = tokens(src[ref]), tokens(value)
if key.endswith("_one") and "{{count}}" in want and "{{count}}" not in got:
want = [t for t in want if t != "{{count}}"] # "One item" without the number is allowed
if want != got:
print(f"ERROR {key}: placeholders changed {want} -> {got}")
errors += 1
if isinstance(value, str) and JAPANESE.search(value):
print(f"WARN {key}: still contains Japanese: {value!r}")
warnings += 1
for key in tgt:
if key not in exp and not key.endswith("_zero"):
print(f"WARN extra key not in source: {key}")
warnings += 1
print(f"\n{len(exp)} keys expected, {len(tgt)} found: {errors} error(s), {warnings} warning(s)")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()
Real output
$ python3 i18n_check.py ja.json en_reply_raw.txt
ERROR en_reply_raw.txt: not valid JSON at line 1, col 1: Expecting value (looks like a chat reply: remove the intro line and the ```
fence)
exit=1
$ python3 i18n_check.py ja.json en_broken.json
WARN nav.settings: still contains Japanese: '設定'
ERROR welcomeBack: placeholders changed ['$t(nav.home)', '{{name}}'] -> ['{{name}}']
ERROR deleteConfirm: placeholders changed ['{{fileName}}'] -> ['{{filename}}']
ERROR missing key: lastLogin
ERROR helpLink: placeholders changed ['{{ link}}'] -> ['{{link}}']
ERROR missing key: cart.items_one (plural form the target language needs; source only has cart.items_other)
ERROR missing key: notifications_one (plural form the target language needs; source only has notifications_other)
ERROR missing key: trialDays_one (plural form the target language needs; source only has trialDays_other)
ERROR missing key: rank_ordinal_one (plural form the target language needs; source only has rank_ordinal_other)
ERROR missing key: rank_ordinal_two (plural form the target language needs; source only has rank_ordinal_other)
ERROR missing key: rank_ordinal_few (plural form the target language needs; source only has rank_ordinal_other)
18 keys expected, 11 found: 10 error(s), 1 warning(s)
exit=1
$ python3 i18n_check.py ja.json en_fixed.json
18 keys expected, 18 found: 0 error(s), 0 warning(s)
exit=0
Every problem in the broken file was caught, including the untranslated 設定 and the $t(nav.home) that became the plain word "Home". The exit code is 1 when there are errors, so you can run it in CI:
bash
python3 i18n_check.py locales/ja/translation.json locales/en/translation.json
For other target languages, pass that language's CLDR categories, for example --forms one,few,many,other for Polish (from the same CLDR chart).
What it doesn't check
- Whether the English is good. Read it, or have someone who knows the product read it.
- Context suffixes such as
_male/_femaleare treated as ordinary keys. - A non-plural key that happens to end in
_otheror_onewill be treated as a plural. Rename it or adjust the regex. - Custom
pluralSeparator,keySeparatoror interpolation prefixes. The script assumes i18next's defaults (_,.,{{ }},$t( )).
When a CLI is the better tool
If you translate locale files often, a dedicated tool can save the copy-paste. One open-source example is i18n-ai-translate (GPL-3.0), which works with i18next JSON and other formats through an AI provider's API or a local Ollama model. I haven't tested it for this article; the checker above works on its output too.
The same "let the AI translate, let a script check the structure" pattern is what I used for subtitles in How to Translate SRT Subtitles with ChatGPT (Japanese to English) Without Breaking the Timing.
Sources (checked October 21, 2026): i18next, "Plurals" (https://www.i18next.com/translation-function/plurals), "JSON Format" (https://www.i18next.com/misc/json-format), "Interpolation" (https://www.i18next.com/translation-function/interpolation); Unicode CLDR, "Language Plural Rules" (https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html); react-i18next, "Step by step guide" (https://react.i18next.com/latest/using-with-hooks); React, "Introducing JSX" (https://legacy.reactjs.org/docs/introducing-jsx.html).
Top comments (0)