Why Text(someString) isn't localized in SwiftUI


TL;DR — Text, Label, Button and confirmationDialog each have two initializers: one takes a LocalizedStringKey and translates, the other takes any StringProtocol and doesn’t. A string literal picks the translating one. A String value can’t become a LocalizedStringKey, so it gets the non-translating one. That means once a literal has been stored in a String (returned from a function, passed as a String parameter), it will never be translated, however complete your string catalog is. Wrap it in String(localized:) where the literal is written, or type the parameter as LocalizedStringKey.

What happened

VertiCut ships in Japanese and English. The string catalog had English for almost everything, and a test confirmed it. Then I ran the app in English and found 22 UI strings still showing in Japanese: confirmation dialogs, notices, labels.

21 of the 22 had an English translation in the catalog. The test that checks translations exist was green the whole time. It was correct, too: the translations did exist. The app was never asking for them.

The two overloads

Every one of these SwiftUI views has a pair of initializers like this:

init(_ key: LocalizedStringKey, ...)                        // translates
@_disfavoredOverload init<S: StringProtocol>(_ content: S)  // doesn't

I confirmed the @_disfavoredOverload attribute in the iPhoneOS SDK’s .swiftinterface. Which one you get depends on whether you pass a literal:

You write Overload chosen Translated?
Text("Hello") LocalizedStringKey Yes
Text(flag ? "Yes" : "No") LocalizedStringKey Yes, a ternary of literals still counts
Text("\(n) seconds") LocalizedStringKey Yes, interpolation becomes a key
Text(someString) StringProtocol No

A string literal can become a LocalizedStringKey. A String value can’t. So the moment a literal is stored in a String, it can no longer be translated.

How the literal ends up in a String

None of these look wrong when you read them. (Simplified versions of the real cases, shown with English text so they’re easy to read. In the app, the literals were Japanese.)

A function that returns String:

var title: String { "Discard your edits?" }
// ...
.confirmationDialog(title, isPresented: $showing) { ... }  // not translated

A helper with a String parameter:

private func notice(_ text: String, icon: String) -> some View {
    Label(text, systemImage: icon)                          // not translated
}
// ...
notice("Analysis may take a while", icon: "clock")

notice reads naturally. Label(text, systemImage: icon) reads naturally. The call site passes a literal. The bug isn’t at the call site or in the view. It’s the parameter type in between.

Fixing those turned up more of the same, in other shapes:

  • An enum payload: phase = .failed("…") with a String associated value.
  • A formatter that returns String: building "\(total) seconds" or "%d min %02d s" in a -> String function.
  • Plain concatenation: "…" + "…" is never a translation key, so two notices built that way were Japanese in every locale.

The fix

Wrap the literal in String(localized:) where it’s written. Then the String already holds the translated text by the time it reaches Text:

var title: String { String(localized: "Discard your edits?") }

notice(String(localized: "Analysis may take a while"), icon: "clock")

The key stays the same, so the catalog and existing translations don’t change.

Or make the helper take what it actually needs:

private func notice(_ text: LocalizedStringKey, icon: String) -> some View {
    Label(text, systemImage: icon)                          // translated
}

For formatters, use one localized string with interpolation (String(localized: "\(total) seconds")) rather than assembling a sentence from pieces. Concatenation also breaks word order in languages that don’t share English’s.

The rule I follow now: don’t carry UI text in String. Wrap it at the literal, or type it as LocalizedStringKey all the way to the view.

Why the tests didn’t catch it

There are two separate claims here:

  1. The catalog has an English translation for this key.
  2. The app asks the catalog for this key.

My test checked only the first. Every failure above was in the second.

The second claim is hard to test at runtime. In a test process running in the Japanese locale, a raw Japanese literal and a correctly translated lookup produce exactly the same bytes. There is nothing to compare.

So the tests read the source instead. They walk the whole app target, every file, and assert:

  • no user-facing sentence is returned as a plain literal from a -> String function
  • no user-facing literal is passed to a parameter typed String (decided by looking at the parameter’s type, not by a hand-maintained list of function names)

Walking the whole target is deliberate. An earlier version of this scan was limited to one directory I’d typed by hand, and 22 cases in another directory were simply outside it. I wrote that one up in Green tests that measured the wrong thing. Before trusting the new scans, I reverted two of the fixes and confirmed the tests failed, naming the exact file and line.

They don’t see everything, and I’ve written down what they miss: helpers defined in another file, and a literal assigned to a let before being passed. They also don’t check whether a translation exists. That’s still the first test’s job, and both tests are needed.

Two smaller ones

A translation can exist and still be wrong. The English for the aspect-ratio list was 9:16・1:1・4:5. The translation had been added, but it still used the Japanese list separator ・. A Japanese separator isn’t an English one. There’s now a test that a Japanese separator doesn’t survive into English.

Delete dead translations. Fixing all this, I found strings with translations that nothing displayed anymore, left over from a screen I’d split apart. They looked like coverage but weren’t used.

Takeaway

In SwiftUI, translation happens when a literal meets LocalizedStringKey. Once a literal has been stored in a String, that can no longer happen. “Every key has a translation” and “the app looks up every key” are separate claims, and the second is the one that fails without anything telling you. If you run your app in your development language, you’ll never see it. Run it in the other one.


I'm Kou (日々野 考), an indie iOS developer in Japan building on-device ML apps under kouworks. I write up the things that cost me days, so they cost you minutes.

Apps:

  • VoiceKit — filler-word removal, subtitles, and summarization, entirely on-device.
  • KidsMask — automatic face mosaic for photos and video; no face data ever leaves the device.
  • VlogMask — real-time face mosaic camera for vlogging.
  • VertiCut — turns horizontal video into vertical 9:16, following a person automatically.