The string literal that cannot be translated
A hardcoded string is not just untranslated — it is invisible to the sweep that finds untranslated text, so coverage reports complete while it is not.
A label is written directly into a composable. It renders correctly, it reads well, and in a single-language app it is indistinguishable from the right thing.
It is also now invisible to every tool that could tell you it needs translating.
The case this post is about
One string, in one composable:
Text("Save changes") invisible to every locale tool
Text(stringResource(Res.save)) an entry; exportable; countable
The first line renders correctly, reviews cleanly, and ships. It is also invisible to every tool that enumerates translatable text — so it is not untranslated, which would show up as a gap. It is unfindable, and the locale coverage report says 100 per cent.
That is the property worth the post. An untranslated string is a known absence; a hardcoded one is not in the denominator at all, so the number that is supposed to catch it reports success.
This post follows that one line, because the gate that finds it has to know which calls render text to a user and which ones take a string for some other reason entirely.
The symptom
The obvious version is a screen that is half translated. The app is in another language, most of it is, and four strings are in English — a button, an empty-state message, a validation error. Those four are the ones somebody wrote inline.
The second thing, and the reason this is worth a post rather than a lint rule, is that you cannot find them by asking. A coverage check compares the translations against the base resource set and reports what is missing. A string that was never a resource is in neither set, so it does not appear as missing — the report says a hundred per cent, and the screen has four English words on it.
That is the property that makes hardcoded strings different from a mere omission. They are not an untranslated item; they are an item that has removed itself from the inventory, and every tool downstream inherits the gap. The translation export does not contain it, so no translator ever sees it. The coverage gate passes. The only way it is found is by a person looking at the screen in that language.

The third is scale. One hardcoded string is a small bug; the problem is that nothing stops the second, and each one shrinks the denominator of a number that still reads as complete. A codebase with seventy hardcoded strings reports perfect coverage just as confidently as one with none.
The fourth is the reasonable path that leads there. Somebody is prototyping a screen, writes labels inline because iterating on copy is faster that way, and intends to extract them before merging. Sometimes they do. The screen is correct either way, so nothing marks the difference.
Why the obvious fix failed
Extract strings at review time. The convention, and it asks a reviewer to spot an absence of indirection in a file full of readable text. Inline copy is often the most legible part of a composable, which is precisely what makes it hard to object to.
A translation pass before release. Catches what is in the resource file and nothing else. The whole problem is that these strings never reached the file, so the pass that is meant to be the safety net operates on exactly the set that was never at risk.
Rely on the locale QA. Someone opens the app in another language and looks. This works and it is the most expensive possible detection: it requires a build, a device, a language, a person and a walk-through of every screen — and it finds them one at a time.
Lint for string literals generally. Right instinct, wrong scope if applied bluntly. Plenty of string literals in a composable are not user-facing: a test tag, a content description key, an analytics event name, a format specifier. A rule that flags all of them produces a suppression habit, and a suppression habit disables the rule for the cases that mattered.
The actual mechanism
Make the resource file the only surface on which user-facing text can exist, and enforce it where text reaches the screen.
Text("Save changes") invisible to every locale tool
Text(stringResource(Res.save)) an entry; exportable; countable
The argument for resources is usually framed as preparation for translation, which undersells it. The real property is that a resource is an inventory item. It can be enumerated, compared against other locales, exported to a translator, counted, and reported on. A literal is none of those things, and no amount of tooling can make it so, because the tooling works by reading the inventory.

That reframing also explains why the gate belongs at the point of display rather than on string literals generally. The rule is not "no literals" — it is "no literal reaches a text-rendering call", which is a narrower and much more defensible check. An analytics key is a literal and never renders; a label is a literal and does. The distinction is mechanical and it is the one that keeps the rule usable.
The sub-case that needs an answer before the gate ships: genuinely non-translatable display text. A version number, a user's own input echoed back, a currency symbol derived at runtime. Those render and should not be resources, so the gate needs an explicit, visible way to say so — and it should be rare enough that its appearance in a diff is itself informative.
There is one more thing the resource surface buys that is easy to overlook: it makes coverage a number. Once every user-facing string is an entry, the question "how translated is this app" has an answer per locale, computed from the base set, reported per release. That number only means something if the inventory is complete, which is exactly what the gate guarantees.

The fix
resources every user-facing string is an entry
gate a literal reaching a text-rendering call fails the build
escape an explicit marker for genuinely non-translatable display text
report per-locale coverage against the base set, per release
The gate is the part worth showing, because its whole value is in being narrow. It fires on a literal reaching a text-rendering call and stays quiet everywhere else — which is what keeps it from becoming a rule people suppress:
// ktlint custom rule — StringLiteralInTextRule.kt
private val RENDERERS = setOf("Text", "BasicText", "TextField", "OutlinedTextField")
private val ESCAPE = Regex("""@NotTranslatable\b""")
override fun visitCallExpression(node: ASTNode, emit: EmitFn) {
val callee = node.calleeName() ?: return
if (callee !in RENDERERS) return // analytics keys, test tags: ignored
val arg = node.firstValueArgument() ?: return
if (!arg.isStringLiteral()) return // stringResource(...) — fine
if (ESCAPE.containsMatchIn(node.precedingComment())) return // declared non-translatable
emit(
arg.startOffset,
"user-facing literal reaching $callee — move it to strings.xml, " +
"or mark it @NotTranslatable with a reason",
canBeAutoCorrected = false,
)
}
Three early returns, in order of how often they fire. The first is what makes the rule usable at
all: Text("Save") is a finding and logEvent("save_tapped") is not, and the difference is the
call being rendered rather than the argument being a literal.
Run over a screen with one inline label:
$ ./gradlew :feature:profile:ktlintCheck
ProfileScreen.kt:48:18 user-facing literal reaching Text — move it to strings.xml,
or mark it @NotTranslatable with a reason
BUILD FAILED in 4s
$ ./gradlew :feature:profile:ktlintCheck # after extraction
BUILD SUCCESSFUL in 3s
The fourth line is the payoff and it is only trustworthy because of the second. Coverage computed over an incomplete inventory is the misleading hundred per cent from the symptom section — the gate is what makes the report mean what it appears to mean.
Ordering matters: the gate has to land before anybody trusts the number. A coverage report introduced first will be believed, and it will be wrong in the direction that produces confidence.
There is a related invisibility that the same discipline fixes and that nobody thinks about until a translator asks: context. A resource entry is a key and a string, and a translator seeing the string alone cannot tell whether a word is a noun or a verb, whether a short label sits on a button or a heading, or whether two entries that read identically in the source language should differ in theirs. The inventory makes the text findable and does not, by itself, make it translatable well.
The cheap remedy is a comment on the entry — what this is, where it appears, what the constraint is. It costs one line at the moment the string is written, by the only person who has the context, and it is the difference between a translation that is correct and one that is merely a valid sentence. The gate guarantees the string reaches the translator; the comment is what lets them do the job.
How we proved it
The invisibility is the demonstrable claim, and it is the one worth checking on any codebase that reports good coverage. Add a hardcoded string to a screen and run the coverage check. The number does not move. That single observation establishes that the metric and the user experience are measuring different things.

The narrow gate is the part that makes it practical. Checking literals at text-rendering call sites rather than everywhere means the rule fires on the cases that matter and stays quiet for test tags and analytics keys — which is what prevents the suppression habit that kills blunter rules.
What I have not measured is how many hardcoded strings existed before the gate. The gate applies going forward; the existing ones were found by the same expensive method the post criticises, and I do not have a count of what had accumulated or over what period.
What I have not solved: the escape's discipline. A marker for non-translatable text is correct and it is also an opening. Nothing currently reviews whether the escapes are genuine, so the rule's integrity depends on people using it honestly — which is the kind of dependency that holds until somebody is in a hurry, and I have no check for it.
The prototyping path deserves a better answer than discipline, because it is the one that produces most of these and the people taking it are not being careless. Writing copy inline while a screen is taking shape genuinely is faster, and the intention to extract before merging is usually sincere. What defeats it is that nothing in the working state distinguishes a literal that is about to be extracted from one that has been forgotten — they are the same code.
The gate resolves that by making the merge the deadline rather than somebody's memory, which is why it matters that it fires at build time rather than in review. A rule enforced by a person asks somebody to notice an absence; a rule enforced by the build asks nobody to notice anything. That is the entire difference in reliability between the two, and it is why the convention version of this had been written down for years and had not worked.
The prototyping objection deserves a real answer rather than a dismissal, because it is the honest reason most of these strings exist. Iterating on copy through a resource file is genuinely slower than typing into the composable, and a rule that ignores that will be worked around. The answer I have settled on is that the gate runs at build rather than in the editor: inline text during exploration is fine and cannot be committed, which keeps the fast loop and closes the surface at the boundary that matters.
That placement is the whole compromise. A check in the editor would be correct and would make exploration worse; a check at release would be too late to be cheap. At the commit boundary the cost lands on the person who was finishing anyway, which is the only moment the extraction is a small job.
And a limitation worth stating: a complete inventory tells you a string is translatable, not that its translation is any good. Coverage counts entries, so a locale at a hundred per cent may be full of machine translations that are grammatically wrong or culturally off. The gate fixes a findability problem and says nothing about quality, and reporting coverage as if it were quality would be the same mistake in a new place.
What this generalizes to
A thing that is not in the inventory cannot be reported as missing, and that makes an incomplete inventory worse than an obviously absent one.
The pattern recurs wherever a check works by enumerating a registry: untracked files that no scan reads, endpoints absent from a specification, events missing from an analytics schema, assets not in a manifest. In each case the report is computed over what was declared, so the undeclared item is not an error — it is outside the universe the report describes, and the report is confident.
So for any coverage figure, ask what the denominator is and what could be absent from it. If the answer is "anything somebody wrote inline", the number is measuring compliance among the compliant. The fix is always the same shape: close the surface so things cannot exist outside the inventory, and only then start trusting what the inventory says.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".