DEV Community

AI Sensei LAB
AI Sensei LAB

Posted on

Translate a Japanese i18next JSON File to English with AI Without Losing {{placeholders}} or Plural Keys

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:

  1. The reply isn't JSON. "Sure! Here is the translation:" plus a ```json fence, and JSON.parse fails.
  2. Keys go missing. Long files can get trimmed, or a key the model found odd can be skipped.
  3. Placeholders change. {{fileName}} becomes {{filename}}, {{- link}} loses its dash, $t(nav.home) gets "translated" into plain text.
  4. 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)"
Enter fullscreen mode Exit fullscreen mode
ja [ 'other' ] [ 'other' ]
en [ 'one', 'other' ] [ 'few', 'one', 'two', 'other' ]
Enter fullscreen mode Exit fullscreen mode

(First array: cardinal categories. Second: ordinal, as in 1st/2nd/3rd.)

So a correct ja.json has only _other keys:

"notifications_other": "{{count}}件の新しい通知"
Enter fullscreen mode Exit fullscreen mode

and a correct en.json needs both:

"notifications_one": "{{count}} new notification",
"notifications_other": "{{count}} new notifications"
Enter fullscreen mode Exit fullscreen mode

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}}位"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 }));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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.
Enter fullscreen mode Exit fullscreen mode

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 (_zero is allowed, since i18next supports it as an optional key for count: 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()
Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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 / _female are treated as ordinary keys.
  • A non-plural key that happens to end in _other or _one will be treated as a plural. Rename it or adjust the regex.
  • Custom pluralSeparator, keySeparator or 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)