StringLane

    Type to search titles, sections and body text across all 58 pages.

    How-To

    Find Unused and Undefined Keys

    stringlane scan reads your source code and reports keys nothing calls, and keys your code calls that do not exist. What it can see, how a live key lands in that list, and why it refuses rather than guessing.

    Last updated

    Every project accumulates them. A screen is redesigned, a feature is cut, a key is renamed and the old one stays behind: still translated into every language you support, still counted against your completion percentage, still sent to your AI provider on the next run, and nothing ever tells you it is dead.

    stringlane scan

    It reads your source code and reports two things.

    Defined, not referenced. Keys your project has that no call site names. A cleanup list.

    Called in code, not defined. Keys your code asks for that your project has never defined. This is a bug that is already shipping, because the screen renders a raw key or crashes, so it is reported first.

    Real output#

    Run against scan-project, a small Flutter test project in the StringLane repository whose code mixes gen-l10n getters with easy_localization's .tr():

    8 keys defined · 7 referenced · 1 not referenced · 1 undefined · 4 source files read
     
    Called in code, not defined (1):
      onboardingTitle  lib/screens/onboarding_screen.dart:17
     
    Some of these may be defined outside this project: a shared library with
    its own locale files, or a key added at runtime.
     
    Defined, not referenced (1):
      legacyPromoBanner
     
    What this means: "not referenced" is what the scanner SAW, not a verdict.
    A key can be live and still appear here if it is built by concatenation,
    called through a wrapper the scanner does not recognise, named only in a
    file type it does not read, or used by a platform that reads your locale
    files directly. Check before deleting.
     
    A family that is always built dynamically can be silenced for good:
      --ignore-key-pattern 'errors.*'   (or ignoreKeyPatterns: in stringlane.yaml)
    For a single key, mark it dynamic in its metadata instead.

    onboardingTitle is reported as undefined because it is called with easy_localization's .tr(), which names the framework; a gen-l10n getter could not be. scan exits 0 with findings: it reports, and check --fail-on unused_key is where a finding becomes a failure.

    The text report caps both lists so that caveat stays on screen. --json carries them whole, with the file and line of every call site.

    It is not a verdict#

    "Not referenced" means no call site was found. It does not mean the key is dead. A key will look unused and be perfectly alive if:

    • its name is built at runtime: t('errors.' + code) is invisible to any scanner;
    • it is called through a wrapper StringLane does not recognise;
    • it is named only in a file type the scanner does not read;
    • it is read directly out of the locale file by a platform, which is how iOS InfoPlist.strings and some Android resources work.

    So the list is where a search starts, not a queue to delete from. Every surface that shows it says so beside the list rather than in a footnote.

    It refuses rather than guessing. If StringLane read no source files at all, or read them and recognised no translation keys, it tells you that and reports nothing as unused. "I could not read your code" and "every key is dead" produce an identical empty list, and only one of them is safe to act on.

    What it reads#

    FamilyExtensionsCall shapes
    Dart.dartAppLocalizations.of(context)!.key and S.of(context).key, including through a name you bound first (final l10n = AppLocalizations.of(context)!; then l10n.key); context.l10n.key under any getter name; S.current.key; easy_localization's 'key'.tr(), tr('key'), context.tr('key'), 'key'.plural(n) and flat LocaleKeys.key.tr(); slang's t.screen.title and context.t.screen.title
    JavaScript.js .jsx .ts .tsx .mjs .cjs .mts .cts .vue .sveltet('key') and its i18n.t / i18next.t / $t / this.$i18n.t spellings, plus <Trans i18nKey="…">
    Swift.swift .m .mmExact: NSLocalizedString("key", …) and String(localized: "key"). Possible: every other single-line string literal, such as Text("Save"), .navigationTitle("Settings"), a string passed to your own view, and SwiftGen's L10n.some.path accessors
    Android.kt .kts .java .xmlR.string.key, and @string/key in layouts and manifests

    The class name in the Dart patterns is not pinned to AppLocalizations, because output-class is a l10n.yaml option that projects do change. What is pinned is the shape. Not followed yet: an alias of an alias (final t = l10n;), the accessor reached through another object (widget.l10n.save), S.current bound to a name, a top-level slang key with no nesting (t.hello), a nested key through easy_localization's LocaleKeys, and any call through a renamed import. A key used only that way appears in the unused list.

    Namespaced i18next projects. Keys carry their namespace, such as common:hello. A qualified call, t('common:hello'), is matched as written. From CLI 0.4.0 an unqualified t('hello') is matched in the default namespace: translation, i18next's own default, unless defaultNamespace: in stringlane.yaml names another. next-i18next projects use common. It does not reach into other namespaces, so with the default left at translation, t('hello') against only common:hello is still reported as undefined, and common:hello as unused.

    The undefined list is narrower than the unused one, on purpose. Undefined keys are computed from exact call sites only. Dart's sites are all recorded as ambiguous, because Foo.of(context).bar is how localizations are read and how Theme.of(context).colorScheme is read. So a Flutter project on gen-l10n, flutter_intl or slang gets the unused list and not the undefined one: the alternative is reporting every theme property in your app as a missing translation. easy_localization is the exception. 'key'.tr() and tr('key') name the framework the way t('key') does in JavaScript, so they are exact, and an easy_localization project gets both lists.

    The same broadness is why the unused list is trustworthy in the direction that matters: a missed reference is what gets a live key deleted, so the patterns stay wide and accept noise in the other column.

    Swift counts possible uses. From CLI 0.7.0, every single-line string in a Swift file counts as a possible use of the key with that text: a SwiftUI view or modifier, your own view (SettingsRow(title: "Privacy")), a labelled argument such as prompt: "Search", a string returned from a case. An interpolated string is read the way Xcode saves it, so Text("\(count) items") matches %lld items, up to four interpolations. A possible use keeps a key off the unused list and never reports a key as undefined.

    That has a cost, and it is the opposite failure to the list above: a dead key whose text also appears as an ordinary string in your code, such as a log message or a dictionary key, counts as used and stays off the unused list. Multi-line """ strings, comment lines and a string with more than four interpolations are not read.

    In stringlane check#

    check runs the same scan in its normal pass, so you get the finding without going looking for it. Both findings are reported at info:

    • your CI does not turn red the day you upgrade;
    • a recorded baseline does not flip.

    To make it fail the build:

    stringlane check --fail-on unused_key

    To skip the scan entirely:

    stringlane check --no-scan

    Passing both is refused rather than accepted. With the scan off nothing can emit unused_key, so the gate would parse, run and never fire, with output indistinguishable from a clean project.

    Silencing a key that only looks dead#

    A whole family that is always built dynamically:

    stringlane scan --ignore-key-pattern 'errors.*'

    Make it permanent in stringlane.yaml:

    ignoreKeyPatterns:
      - 'errors.*'
      - 'InfoPlist.*'
    ignoreFilePatterns:
      - '**/*.g.dart'

    The flags add to those lists rather than replacing them, so a one-off run can silence something without editing the file. --ignore-file takes a path glob relative to the project root, and a pattern with no / in it is about a filename, so --ignore-file '*.g.dart' matches a generated file at any depth.

    One key, not a family#

    A pattern is the wrong tool for a single key that happens to be built at runtime. Mark that key called dynamically instead, which is a per-key record rather than a rule: x-dynamic-key in the @key block on ARB, and in .stringlane/metadata.yaml on the other four formats. The desktop app writes it from the Key Metadata panel; see Find keys your code no longer uses.

    The scanner reads it before it reads your patterns, so a key that is both flagged and pattern-matched is reported as silenced by the flag: the per-key decision is the more specific one, and it is where someone who doubts the count has to go. Either way the key is still counted in the summary, and counted separately by cause, so a report reading 0 not referenced because everything was silenced cannot pass for a clean project.

    With a coding agent#

    The MCP server's read_key_usage tool answers the same question: the call sites of one key, or the project's not-referenced set.

    It exists so your agent does not grep your tree and get a worse answer. The merged key a plan spells is not always the string your code contains: a multi-origin project prefixes it and the runtime never sees that prefix, so a search for the wrong spelling reports every key as unused, which looks exactly like a filthy codebase.

    describe_keys also tells the agent which of the keys it has documented nothing calls, so it stops writing descriptions for dead ones.

    What it costs#

    About 75 microseconds per source file. A ten-thousand-file repository takes roughly three quarters of a second, and the scanner stops at twenty thousand files, so the worst case is around a second and a half. Within a single run the second scan re-checks timestamps but does not re-read the files.

    Before you delete anything#

    Two rules, both learned the hard way:

    1. Confirm each key yourself. Search for it, including for the runtime spelling the report names when it differs from the merged one.
    2. Never delete keys in the same change as anything else. A deletion that turns out to be wrong needs to be revertible on its own.

    Frequently asked questions

    How do I find translation keys my app no longer uses?
    Run stringlane scan. It reads your source code and lists keys the project defines that no call site names. Treat it as a starting point for a search rather than a delete queue: a key built at runtime, called through a wrapper StringLane does not recognise, or read straight out of the locale file by the platform will appear in that list and still be live.
    Does stringlane check fail my build on unused keys?
    No. check runs the same scan in its normal pass and reports both findings at info severity, so upgrading the CLI does not turn a pipeline red and a recorded baseline does not flip. Pass --fail-on unused_key to opt in, or --no-scan to skip the scan entirely. Passing both is refused, because with the scan off nothing could ever emit that code.
    Why does my Flutter project report no undefined keys?
    Because the gen-l10n, flutter_intl and slang call shapes are ambiguous by nature: Foo.of(context).bar is how localizations are read and also how Theme.of(context).colorScheme is read. Undefined keys are computed from exact call sites only, so a project whose sites are all ambiguous gets the unused list and not the undefined one. easy_localization is the exception: tr(key) and key.tr() name the framework, so those calls are exact and an easy_localization project gets both lists.

    Free · In your coding agent

    Run the same checks from your coding agent

    Paste this into Claude Code, Cursor, Codex or five other agents. It sets up the CLI and MCP server for this project, proposing each command and waiting for your yes.

    Read https://stringlane.app/docs/cli/agent-setup.md and set up StringLane for this project.

    Or install it yourself:

    npm install -g @stringlane/cli