Finding orphaned keys after a refactor
A feature is removed, a component is rewritten, a page is redesigned. The code is clean. The catalogue still carries every string those surfaces used, in every locale, and will carry them for years — translated, exported, counted toward coverage, and sent to translators whenever the source is edited.
Orphaned keys are not a correctness bug. They are a cost bug: translation spend on strings nobody renders, review effort on diffs nobody needs, and a catalogue whose size stops meaning anything.
Root cause: deletion is asymmetric
Removing a call site is a local change with an obvious diff. Removing the corresponding catalogue entry is a change in a different file, in every locale, with no compiler or test to notice it was skipped. So it is skipped — not out of carelessness, but because nothing in the workflow connects the two.
The asymmetry is compounded by risk. Deleting a key feels dangerous in a way that deleting code does not, because the consequence of a mistake is invisible: a missing string in a language the author cannot read. Faced with that asymmetry, the rational individual choice is to leave the key, and the aggregate result is a catalogue that only grows.
Building a scan that can be trusted
The naive version — grep the source for each catalogue key — produces a report nobody acts on, because it is wrong in both directions. Keys assembled at runtime look unused; keys referenced only by tests look used.
A trustworthy scan needs three inputs: literal keys found by walking the syntax tree, an explicit allowlist for dynamic prefixes, and a scope that excludes test files.
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import fg from 'fast-glob';
import fs from 'node:fs';
const referenced = new Set<string>();
// Prefixes assembled at runtime — declared, reviewed, and deliberately small.
const DYNAMIC_PREFIXES = ['status.', 'error.code.', 'country.'];
for (const file of await fg(['src/**/*.{ts,tsx}', '!src/**/*.test.*'])) {
const ast = parse(fs.readFileSync(file, 'utf8'), {
sourceType: 'module', plugins: ['typescript', 'jsx'],
});
traverse(ast, {
CallExpression(path) {
const callee = path.node.callee;
const isT = (callee.type === 'Identifier' && callee.name === 't') ||
(callee.type === 'MemberExpression' && (callee.property as any).name === 't');
if (!isT) return;
const arg = path.node.arguments[0];
if (arg?.type === 'StringLiteral') referenced.add(arg.value);
// A template or a binary expression is dynamic: the allowlist must cover it.
},
});
}
const orphans = Object.keys(catalog).filter((key) =>
!referenced.has(key) && !DYNAMIC_PREFIXES.some((p) => key.startsWith(p)));
The allowlist is the part that makes this honest. Every dynamic prefix in it is a place where extraction cannot see the key, which means it is also a place where a missing translation will not be caught by any other gate. Keeping the list short is a design goal, not just a scan detail.
Mark first, delete later
An orphan sweep that deletes on the first pass will eventually delete something that was in use — through a revert, a feature flag, or a code path the scan could not see. Marking makes the sweep safe to automate.
const today = new Date().toISOString().slice(0, 10);
const removeAfter = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10);
for (const key of orphans) {
catalog._meta ??= {};
catalog._meta[key] ??= { deprecated: today, removeAfter, reason: 'no call site found' };
}
A second job, run on a schedule, deletes only entries whose removeAfter has passed and which are still unreferenced by a fresh scan. Two independent confirmations three months apart is a high enough bar that the remaining risk is negligible.
Deleting a key means deleting it in every locale. A key removed from the source catalogue but left in the targets becomes an orphan of the opposite kind — a translation with nothing to translate — which most synchronisation tools will report as an unexpected extra unit.
What the orphan count is actually telling you
A raw orphan number is not very interesting. The two derived numbers are.
The first is the rate of change. A catalogue that gains fifteen orphans a quarter and clears twelve is healthy; one that gains fifteen and clears none has no deletion path at all, and the absolute count only tells you how long that has been true. Tracking the delta rather than the total is what turns the sweep from an occasional cleanup into a signal.
The second is the distribution across namespaces. Orphans concentrated in one namespace usually mean a surface was rewritten and its old strings were left behind — a single, bounded cleanup that one person can do in an afternoon. Orphans spread evenly across every namespace mean something structural: extraction is running with settings that preserve everything, or the team has no habit of removing strings at all. Those need different responses, and the total count cannot distinguish them.
There is also a category worth separating out before either number is computed: keys that are unreferenced because they are not yet referenced. A branch that adds catalogue entries ahead of the code that will use them — common when translation lead time is long — produces orphans that are actually pre-work. Marking those explicitly at the time they are added, rather than letting the next sweep discover and deprecate them, avoids a confusing cycle where the sweep deprecates strings the team deliberately ordered early.
Finally, be careful about comparing counts across a namespace split. Splitting one namespace into three does not change how many keys are unused, but it does change which scan scope sees them, and a drop in the reported number immediately after a reorganisation is usually an artifact rather than progress.
Verification
npx tsx scripts/orphan-scan.ts --report
# Expected
# 2841 keys in catalogue
# 2794 referenced from source
# 47 unreferenced
# ├─ 39 already marked (earliest removeAfter 2026-09-14)
# └─ 8 newly marked today
# 0 keys referenced but missing from the catalogue
The last line matters as much as the first. A scan that walks call sites can report both directions, and a referenced-but-missing key is a live bug rather than a cleanup opportunity — the same class of defect covered in detecting hardcoded strings.
When to escalate
If the orphan count is large and stable rather than shrinking, the sweep is probably running against the wrong scope. Server-rendered templates, email layouts and configuration files all reference keys and are frequently excluded from a scan aimed at a front-end source tree.
If a key is deleted and a string then goes missing in production, the cause is almost always a dynamic prefix that was never added to the allowlist. Recovering the translations from the memory is usually possible — see translation memory and glossary management — and the durable fix is to replace the dynamic key with an explicit map, so the scanner and the extractor can both see it.
If orphans accumulate specifically around one team’s surfaces, the issue is ownership rather than tooling. A namespace without an owner has nobody who feels responsible for its size, which is the argument for the ownership split in string catalog governance.
FAQ
Should the orphan scan fail the build?
No — report, do not fail. Orphans are a cost problem rather than a correctness problem, and a build that fails because a key became unused will be worked around within a week. The gate that should fail the build is the opposite direction: a key referenced in code but absent from the catalogue.
How do I handle keys used only by a feature flag that is currently off?
Treat the flag as a call site. If the code path exists in the repository, the scan will find the literal and the key is not an orphan, which is the correct answer — the flag may be turned on tomorrow. Keys become orphans when the code is deleted, not when it is disabled.
What about keys referenced from another repository?
They will always look orphaned, so they need to be excluded explicitly. A shared catalogue consumed by more than one application is really a package with its own contract, and its unused-key analysis has to run across every consumer — which is one of the arguments for the packaging approach in monorepo i18n package architecture.
Is it worth deleting orphans at all if storage is free?
Storage is free; attention is not. Every orphan is a line in a diff someone reviews, a unit in a translation export someone pays for, and a row in a coverage report that makes the number less meaningful. A catalogue with a fifth of its keys dead is one where nobody trusts the coverage percentage, which is the metric everything else is gated on.
Can extraction just regenerate the catalogue and drop what is missing?
Some extractors offer exactly that, usually as a flag such as keepRemoved: false. It is fast and it is destructive: anything the extractor cannot see is deleted immediately, in every locale, with no window to notice. The behaviour and its consequences are covered in extracting translation keys with i18next-parser.
Related
- String Catalog Governance — the deprecation state this sweep writes into.
- Extracting translation keys with i18next-parser — the extractor flag that deletes instead of marking.
- Safely renaming a translation key — a rename that goes wrong looks exactly like an orphan.
- Translation Memory & Glossary Management — where the translations of a deleted key can still be recovered.
Part of String Catalog Governance.