Duplicate keys across namespaces
Two catalogue files both define title. Both load on the same page. One says “Overview” and one says “Checkout”, and which one a component receives depends on the order the namespaces happen to be listed in. No error is raised, no warning is logged, and the bug reproduces only for the person whose namespace list is ordered differently.
Duplicate keys are the quietest catalogue defect there is, because a resolver’s job is to return the first match — and it does that perfectly.
Root cause: unqualified lookups resolve by order
Namespace-aware resolvers such as i18next take a list of namespaces and search them in order. When a component asks for title without saying which namespace it means, the resolver walks the list and returns the first definition it finds. That is documented, deterministic behaviour, and it is exactly what makes duplicates dangerous: the answer is stable per configuration and different across configurations.
The list order is rarely explicit. It comes from the defaultNS setting, from the order of a useTranslation(['checkout', 'common']) argument, or from the order namespaces were loaded — which in a lazily-loaded app can depend on navigation history. A user who lands on the checkout page directly may get one string; a user who arrives from the dashboard may get the other.
Duplicates also break every tool that treats the catalogue as a map. A merge of namespaces into a single object for a build, a coverage count, or an export to a translation format will silently drop one of the two definitions — and which one it drops depends on iteration order.
Minimal reproducible example
// locales/en/common.json
{ "title": "Overview", "cancel": "Cancel" }
// locales/en/checkout.json
{ "title": "Checkout", "submit": "Pay now" }
// Both namespaces are loaded; the lookup is unqualified.
const { t } = useTranslation(['common', 'checkout']);
t('title'); // → "Overview" — common wins, silently
t('checkout:title'); // → "Checkout" — explicit, correct
Reordering the array to ['checkout', 'common'] flips the first result without changing a line of catalogue or component logic. That is the whole bug.
The fix: detect at build time, qualify at the call site
Detection is a dozen lines and belongs in CI, because duplicates are introduced by ordinary work — a copy-paste, a namespace split — rather than by carelessness that review reliably catches.
import fs from 'node:fs';
import path from 'node:path';
const dir = 'locales/en';
const owner = new Map<string, string>();
const clashes: string[] = [];
for (const file of fs.readdirSync(dir)) {
const ns = path.basename(file, '.json');
for (const key of Object.keys(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')))) {
const prior = owner.get(key);
if (prior) clashes.push(`${key}: defined in both ${prior} and ${ns}`);
else owner.set(key, ns);
}
}
if (clashes.length) {
console.error('duplicate keys across namespaces:\n ' + clashes.join('\n '));
process.exit(1);
}
The second half is a lint rule that requires every lookup to name its namespace, which removes the ordering dependency entirely:
// Explicit namespace — resolution no longer depends on list order.
const { t } = useTranslation('checkout');
t('title'); // scoped to checkout by the hook
t('common:cancel'); // reaching into another namespace, visibly
Choosing the right resolution
Detection tells you a key is duplicated; it does not tell you what to do about it. The three cases need different answers, and picking the wrong one is worse than the duplicate.
If both definitions are genuinely the same string with the same meaning — “Cancel” on a dialog and “Cancel” on a form — one definition belongs in the shared namespace and the other should be deleted. This is the only case where sharing is correct.
If the English happens to match but the meanings differ, keep both, and make both keys specific enough that the coincidence is not repeated. “Open” as a verb on a button and “Open” as a status label are the same word in English and different words in German, Spanish and Japanese. Sharing them produces a string that is correct in exactly one language.
If two different strings collided on one key, rename the newer one. Merging their values — picking whichever wording seems better — silently changes the copy on a surface nobody reviewed.
Making the resolver refuse ambiguity
Detection at build time catches duplicates that exist. A stricter option catches the dependency on ordering itself, by making an unqualified lookup fail loudly in development instead of quietly returning the first match.
import i18next from 'i18next';
if (process.env.NODE_ENV !== 'production') {
const original = i18next.t.bind(i18next);
i18next.t = ((key: string, ...rest: unknown[]) => {
if (!key.includes(':')) {
const owners = LOADED_NAMESPACES.filter((ns) => i18next.exists(`${ns}:${key}`));
if (owners.length > 1) {
throw new Error(
`[i18n] "${key}" is ambiguous — defined in ${owners.join(', ')}. ` +
`Qualify it, e.g. "${owners[0]}:${key}".`
);
}
}
return original(key, ...rest);
}) as typeof i18next.t;
}
Two things make this worth the twenty lines. It fails at the exact call site rather than in a build report, so the developer who introduced the ambiguity is the one who sees it. And it catches duplicates that the static scan cannot — keys that collide only when a particular combination of namespaces is loaded together, which is common in an application with lazily loaded routes.
Keep it out of production. In a production bundle the same check costs a lookup per translation and can turn a cosmetic problem into a blank page, which is a strictly worse outcome than returning the wrong one of two strings.
Verification
npx tsx scripts/check-duplicate-keys.ts # cross-namespace collisions
npx eslint src --rule 'i18n/qualified-key: error'
# Expected
# ✓ 0 duplicate keys across 6 namespaces (2841 keys)
# ✓ 0 unqualified lookups
Run the first check against every locale, not only the source. A duplicate that exists in en but not in de usually means one of the two definitions was never translated, which is a second defect hiding behind the first.
When to escalate
If duplicates keep reappearing after the gate is in place, the namespace boundaries are wrong. Two surfaces that keep needing the same strings are one surface as far as the catalogue is concerned, or there is a shared namespace missing between them.
If the duplicate is between your catalogue and a third-party component’s, the gate cannot see it and the resolution is different: give the component its own namespace and load it under a prefix, so its title can never collide with yours.
If a duplicate turns out to be intentional — a deliberate override of a shared string for one surface — that is a feature the resolver should express explicitly, through a scoped namespace loaded after the shared one, rather than by relying on list order. Ordering that carries meaning should be documented next to the configuration that sets it, as described in string catalog governance.
FAQ
Is a duplicate key always a bug?
Not always, but an undetected one is. Deliberate overrides exist, and they are legitimate when the ordering is explicit and documented. What makes duplicates dangerous is that they usually arrive by accident and behave correctly until the namespace order changes.
Should keys be globally unique, or unique per namespace?
Unique per namespace, with every lookup qualified. Global uniqueness forces prefixes into every key and duplicates the namespace information twice. Per-namespace uniqueness plus qualified lookups gives the same guarantee without the redundancy.
How do I audit a catalogue that already has hundreds of duplicates?
Sort them by whether the values match. Identical values are almost always safe to consolidate into the shared namespace and can be handled in bulk. Differing values need a human decision each, but they are usually a small minority — and they are the ones actually producing wrong strings today.
Does this apply to flat single-file catalogues?
The cross-namespace version cannot occur, but the same defect appears as a key defined twice in one JSON object, where the later definition silently wins at parse time and no tool reports it. A schema check that rejects duplicate object keys catches that, and it is worth having even in a single-file catalogue.
Can a duplicate key ever be a merge artifact rather than a real change?
Frequently. Generated catalogues conflict on the same lines that hand-written code would, and resolving a JSON conflict by keeping both sides produces a file with the key defined twice. Most parsers accept that silently and keep the last occurrence. The habit that avoids it entirely is never to resolve a catalogue conflict by hand — take either side, then re-run extraction, which regenerates a correct file from the source of truth.
Do duplicate keys affect translation cost?
Yes, twice over. The same string is sent for translation once per namespace, so you pay for it twice and receive two independently worded translations. Those then diverge over time, and the interface shows two different words for the same concept in every language except English — which is precisely the inconsistency a termbase exists to prevent.
Related
- String Catalog Governance — namespace ownership and the naming rules that prevent collisions.
- Splitting a monolithic catalog into namespaces — the operation that most often introduces duplicates.
- React i18next Component Patterns — how namespace scoping works at the call site.
- GitHub Actions i18n CI Gates — where the detection script belongs.
Part of String Catalog Governance.