DEV Community

Cover image for I built a zero-dependency CLI that catches i18n drift (and knows your plural rules)
benjamin
benjamin

Posted on

I built a zero-dependency CLI that catches i18n drift (and knows your plural rules)

You add one string to en.json, ship it, and move on.

Three weeks later a French user emails a screenshot: a raw auth.errors.locked
sitting where a translated message should be. fr.json never got the key. Nor
did zh.json. And nobody noticed, because a missing translation doesn't throw —
it just quietly renders the key.

Locale drift is the classic absence bug: the failure isn't an error you can
catch, it's a translation that was never written. Your test suite is green, your
build passes, and the gap only shows up in production, in a language you don't
read.

Why I didn't just use an existing tool

There are i18n tools out there, but every one I reached for had a catch:

  • Framework-locked. i18next-cli only understands i18next. Most "find missing translations" tools assume one specific library's runtime.
  • Account required. Locize and friends want you to push your strings to their platform first.
  • Heavy. AST-based linters that need config and a plugin per framework, when all I wanted to know was "which keys are missing from which file."

And the deal-breaker: most naive "diff two locale JSONs" scripts get plurals
wrong
.

The plural problem (this is the interesting part)

Here's the trap. English has two plural forms: items_one and items_other.
So the obvious rule is "every other locale must have both forms too."

That rule is wrong.

Chinese has one plural category — other. A zh.json with only
items_other is correct. Flagging it as "missing items_one" is a false
positive, and false positives are how a linter ends up ignored.

Meanwhile Russian has four categories (one, few, many, other). A
ru.json that copied English's two-form shape is genuinely broken — and a
parity-with-English check would never catch it, because English doesn't have
few or many to compare against.

So the same items_other-only shape is:

en base: items_one + items_other
zh.json  →  items_other only      ✓ in sync   (Chinese needs only `other`)
fr.json  →  items_other only      ✗ missing {one}
ru.json  →  items_one + _other    ✗ missing {few, many}
Enter fullscreen mode Exit fullscreen mode

To get this right you have to resolve the required CLDR plural categories from
the target language
, not from the base file. So that's what localediff does:
it carries a conservative table of CLDR cardinal categories, infers each file's
language from its name (fr.jsonfr, pt-BR.jsonpt), and checks
completeness against that language's rules. Unknown language? It falls back to
parity with the base, so you never get a confidently-wrong answer.

How it works

Point it at a folder. It treats en.json as the base and checks everything else:

npx localediff ./locales
Enter fullscreen mode Exit fullscreen mode
✗ fr.json
  missing (1): auth.errors.locked
  plural  cart.items — has {other}, missing {one}
  empty   (1): footer.copyright
  extra   (1): legacy.banner

✓ zh.json — in sync

✗ 1 of 2 file(s) drifted — 1 missing, 1 plural gap(s), 1 empty, 1 extra
Enter fullscreen mode Exit fullscreen mode

Four things it reports:

Check What it means
missing a key in the base the target never translated
plural a pluralized key missing a CLDR form the target language needs
empty a key that exists in the target but the value is blank — the slot's there, nobody filled it
extra a leftover key the base dropped (usually after a rename)

It's structural, so it doesn't care what framework you use — next-intl,
react-intl, i18next, vue-i18n, or a plain JSON catalog all work. Nested objects
flatten to dot-paths (auth.errors.locked); arrays get indexed.

It exits non-zero on drift, so it drops straight into CI:

- run: npx localediff ./locales
Enter fullscreen mode Exit fullscreen mode
exit 0  every checked file is in sync
exit 1  something drifted
exit 2  error (file not found, bad JSON)
Enter fullscreen mode Exit fullscreen mode

There's a --format json for machine output, --lang to force a language when
file names aren't locale codes, and --ignore-plural / --ignore-extra /
etc. when you want to narrow the gate.

Install

It ships on both registries with byte-for-byte identical behavior, because
half the projects I work on are Node and half are Python:

npx localediff ./locales      # Node — zero deps, stdlib only
pip install localediff        # Python — pure stdlib
Enter fullscreen mode Exit fullscreen mode

Zero dependencies on purpose: no install bloat, nothing to audit, and npx /
pip install is the whole setup.

A few design notes

  • Zero deps means JSON only. Supporting YAML or .properties would mean pulling in a parser, which defeats the point. Convert to JSON, or tell me I'm wrong about the tradeoff.
  • _other is the tell. A key only counts as a plural group if it has an _other form (the one i18next always requires). That keeps ordinary keys like step_one / step_two from being mistaken for plurals.
  • Conservative on unknown languages. Better to miss a gap than to invent one. Confidently-wrong i18n linting is worse than no linting.

Try it / break it

Code, issues, and the full README:

It's MIT and tiny. I'd love to know where the plural table is wrong for your
language, or what your locale files do that I didn't think of.

How do you currently catch a translation that silently went missing — CI check,
review checklist, or a user emailing you a screenshot?

Top comments (0)