Monorepos

Orphan Detection

The orphans and check commands share one scanner. It parses your source, and a key counts as used only where the call it sits in resolves to a translation function: emit('save') is not a usage, and a t you renamed to translate still is. A file the parser cannot read falls back to pattern matching, which matches call shapes by name instead; summary.filesDeclined counts those files. What the scanner sees decides which keys are safe to delete.

the-i18n-cli orphans

Evidence It Recognizes

ClassExampleEffect
Static keyst('a.b.c'), $te('a.b'), __('a.b')Exact match
Template patternst(`a.b.${x}`)Keys matching a.b.<one segment> count as used
Same-file const prefixesconst base = 'a.b' then t(`${base}.title`)Resolved to the exact key
Unresolved variable segmentst(`${somePath}.title`)Conservatively matches any *.title key
Concatenated prefixes'a.b.' + x, x + '.label'Pattern-matched like templates
Multiline callsThe prefix on a different line from t(Caught by a bare-string fallback

Usage is then scoped: a key in a shared layer counts as used only from apps that consume that layer. See layers and the consumer graph.

The Buckets

Only orphanKeys is ever removed. Everything else is protective:

FieldMeans
orphanKeysNo evidence of use in any consuming app — safe to remove
uncertainKeysEvidence is ambiguous, such as an existence-check-only probe — never removed
candidateOnlyKeysKept alive only by a dotted string that is not a call — not orphans, but where dead references hide
misplacedUsagesUsed only from non-consuming apps — never removed
dynamicKeysThe dynamic expressions the scan found, with file and line

A key that could be produced by a template pattern, a concatenated prefix or an ambiguous probe counts as used and is left alone; summary.dynamicMatchedCount, summary.ignoredCount and summary.declaredCount say how many were protected that way, by orphanScan.ignorePatterns and by a declared namespace. The run reports; --remove deletes.

Extracting Undefined Keys

the-i18n-cli check --write adds every undefined key to a locale file as an empty string, in the default locale only, and never overwrites a value already there. The keys land in the layer the using code resolves against; where that is more than one layer, the run refuses and asks for --layer <name>. A written key has a definition and is no longer undefined, so a run that wrote all of them exits 0 and one with findings left over still exits 2.

Declared Namespaces

Some keys exist by contract rather than by a call site: a backend sends the key name, a call keys them by runtime data, a registry defines the set. The scanner finds no reference and reports them as orphans. Declare the pattern and what keeps it alive:

i18n-kit.config.ts
import { defineI18nKitConfig } from '@the-i18n-kit/cli/config'

export default defineI18nKitConfig({
  declaredNamespaces: [
    { pattern: 'views.defaults.**', reason: 'sent by bookings-api as name_key' },
  ],
})

Declared keys are never orphans, never removed, and never written by check --write, in every layer. orphans lists each declaration with the keys it covers under declaredNamespaces; one with an empty matchedKeys covers nothing in the catalog. The ESLint plugin requires an // i18n-namespace: views.defaults.** comment on a runtime-keyed call and checks the pattern against this config. orphanScan.ignorePatterns, keyed by layer, suppresses the rest — prefixes held in cross-file constants or object properties, which widen to suffix patterns rather than resolve.

Anchor Dynamic Keys, Do Not Avoid Them

Dynamic keys are tracked and are often the right design. What matters is that the namespace stays literal at the call site:

t(`${prefix}.title`)                          // widens to any key ending .title
t(`components.integrations.${type}.title`)    // one segment under a known namespace

Both count as used, but the first suppresses every .title key in the project — keys the scan can then no longer audit. A literal leading segment costs nothing and keeps the report specific. Where the set of keys is closed, a map of literals is the fully static alternative:

const KEYS = { draft: 'x.status.draft', sent: 'x.status.sent' } as const
t(KEYS[state])

Inline the namespace rather than assembling it elsewhere: a computed returning 'a.b.' + x reaches the call site as an opaque variable.

Run removals as a reviewed pull request, and treat the report's uncertainKeys and dynamicKeys sections as the audit trail.

Copyright © 2026