Best practices for internationalizing Swift/SwiftUI applications using LocalizedStringResource, String Catalogs (.xcstrings), and type-safe localization patterns...
Comprehensive guide for implementing localization in Swift and SwiftUI applications using modern Apple frameworks and type-safe patterns.
All text visible to users must be localized, including:
Bad:
Text("Add Weight")
Button("Save") { ... }
.alert("Error", message: "Something went wrong")
Good:
Text(L10n.Common.addWeight)
Button(L10n.Common.saveButton) { ... }
.alert(L10n.Common.errorTitle, message: L10n.Errors.genericMessage)
Create a centralized enum structure (commonly named L10n) using LocalizedStringResource for compile-time safety.
Create a hierarchical enum structure organized by feature or screen:
import Foundation
enum L10n {
enum Common {
static let saveButton = LocalizedStringResource(
"common.button.save",
defaultValue: "Save"
)
static let cancelButton = LocalizedStringResource(
"common.button.cancel",
defaultValue: "Cancel"
)
static let errorTitle = LocalizedStringResource(
"common.alert.errorTitle",
defaultValue: "Error"
)
}
enum Dashboard {
static let navigationTitle = LocalizedStringResource(
"dashboard.navigation.title",
defaultValue: "Dashboard"
)
static func greeting(_ name: String) -> LocalizedStringResource {
LocalizedStringResource(
"dashboard.greeting",
defaultValue: "Hello, \(name)!"
)
}
}
enum Settings {
static let navigationTitle = LocalizedStringResource(
"settings.navigation.title",
defaultValue: "Settings"
)
static let themeTitle = LocalizedStringResource(
"settings.personalization.theme.title",
defaultValue: "Theme"
)
}
}
Use dot-separated namespaces that mirror your code structure:
{feature}.{component}.{element}.{property}dashboard.currentWeight - Simple valuecommon.button.save - Reusable buttonsettings.section.personalization.title - Nested section titleaddEntry.error.invalidWeight - Error messageFor strings with dynamic content, use functions that return LocalizedStringResource:
enum L10n {
enum Dashboard {
static func latestEntry(_ time: String) -> LocalizedStringResource {
LocalizedStringResource(
"dashboard.latestEntry",
defaultValue: "Latest: \(time)"
)
}
static func averageEntries(_ count: Int) -> LocalizedStringResource {
LocalizedStringResource(
"dashboard.averageEntries",
defaultValue: "Average of \(count) entries"
)
}
}
}
Convert LocalizedStringResource to String using String(localized:):
struct DashboardView: View {
var body: some View {
NavigationStack {
VStack {
Text(L10n.Dashboard.navigationTitle)
Button(L10n.Common.saveButton) {
save()
}
}
.navigationTitle(String(localized: L10n.Dashboard.navigationTitle))
}
}
}
For string interpolation or concatenation:
TextField(
String(localized: L10n.AddEntry.weightPlaceholder),
text: $weightText
)
.accessibilityLabel(String(localized: L10n.Accessibility.weightValue))
.accessibilityHint(String(localized: L10n.Accessibility.weightValueHint(unitSymbol)))
Always localize accessibility strings separately:
enum L10n {
enum Accessibility {
static let addWeightEntry = LocalizedStringResource(
"accessibility.label.addWeightEntry",
defaultValue: "Add weight entry"
)
static let addWeightEntryHint = LocalizedStringResource(
"accessibility.hint.addWeightEntry",
defaultValue: "Opens form to log a new weight"
)
static func weightValueHint(_ unitSymbol: String) -> LocalizedStringResource {
LocalizedStringResource(
"accessibility.hint.weightValue",
defaultValue: "Enter your current weight in \(unitSymbol)"
)
}
}
}
Usage:
Button {
showAddEntry = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(String(localized: L10n.Accessibility.addWeightEntry))
.accessibilityHint(String(localized: L10n.Accessibility.addWeightEntryHint))
Modern Swift projects use .xcstrings files (String Catalogs) instead of .strings files. Xcode automatically generates entries when you use LocalizedStringResource.
Location: {ProjectName}/Localization/Localizable.xcstrings
Example structure:
{
"sourceLanguage" : "en",
"strings" : {
"common.button.save" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Save"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Guardar"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enregistrer"
}
}
}
}
},
"version" : "1.0"
}
LocalizedStringResource entries - Xcode will detect themLocalizable.xcstrings in Xcodenew, translated, needs_review+.xcstrings filesenum L10n {
enum Settings {
static let iCloudSyncEnabled = LocalizedStringResource(
"settings.data.iCloudSync.enabled",
defaultValue: "On"
)
static let iCloudSyncDisabled = LocalizedStringResource(
"settings.data.iCloudSync.disabled",
defaultValue: "Off"
)
}
}
// Usage
Text(isEnabled ? L10n.Settings.iCloudSyncEnabled : L10n.Settings.iCloudSyncDisabled)
enum L10n {
enum AddEntry {
static let errorInvalidWeight = LocalizedStringResource(
"addEntry.error.invalidWeight",
defaultValue: "Please enter a valid weight"
)
static func errorSaveFailure(_ message: String) -> LocalizedStringResource {
LocalizedStringResource(
"addEntry.error.saveFailure",
defaultValue: "Failed to save entry: \(message)"
)
}
}
}
// Usage
.alert(L10n.Common.errorTitle, isPresented: $showingError) {
Button(L10n.Common.okButton) { showingError = false }
} message: {
Text(L10n.AddEntry.errorInvalidWeight)
}
For count-dependent strings, use string interpolation:
static func days(_ count: Int) -> LocalizedStringResource {
LocalizedStringResource(
"common.value.days",
defaultValue: "\(count) days"
)
}
In .xcstrings, you can add plural rules:
"common.value.days" : {
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld day"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld days"
}
}
}
}
}
}
}
Let Foundation handle date localization:
// Good - respects user's locale
Text(date, style: .date)
Text(date, format: .dateTime.day().month().year())
// Avoid hardcoded formats
Text(dateFormatter.string(from: date)) // ❌ if format is hardcoded
Audit for hardcoded strings:
# Find potential hardcoded user-facing strings
grep -r 'Text("' --include="*.swift" .
grep -r 'Button("' --include="*.swift" .
grep -r '.alert("' --include="*.swift" .
Create L10n entries:
Replace in views:
// Before
Text("Current Weight")
// After
Text(L10n.Dashboard.currentWeight)
Build and verify:
.xcstrings entriesIf migrating from NSLocalizedString:
// Old pattern
NSLocalizedString("key", comment: "Description")
// New pattern
LocalizedStringResource("key", defaultValue: "Default Value")
Both work with .xcstrings, but LocalizedStringResource is preferred for SwiftUI.
Test string length and layout issues:
// Unit test to ensure no hardcoded strings in views
func testNoHardcodedStrings() {
let source = try! String(contentsOfFile: "DashboardView.swift")
let pattern = #"Text\("[^L]"#
let regex = try! NSRegularExpression(pattern: pattern)
let matches = regex.matches(in: source, range: NSRange(source.startIndex..., in: source))
XCTAssertEqual(matches.count, 0, "Found hardcoded Text strings")
}
✅ Do:
LocalizedStringResource for type safety❌ Don't:
# Extract strings from code (legacy .strings format)
genstrings -o en.lproj *.swift
# Export for translation
xcodebuild -exportLocalizations -project MyApp.xcodeproj -localizationPath ./Localizations
# Import translations
xcodebuild -importLocalizations -project MyApp.xcodeproj -localizationPath ./Localizations/fr.xcloc
| Task | Pattern |
|---|---|
| Simple string | static let title = LocalizedStringResource("key", defaultValue: "Title") |
| Parameterized | static func greeting(_ name: String) -> LocalizedStringResource { ... } |
| In SwiftUI | Text(L10n.Feature.label) |
| With String conversion | String(localized: L10n.Feature.label) |
| Navigation title | .navigationTitle(String(localized: L10n.Feature.title)) |
| Accessibility | .accessibilityLabel(String(localized: L10n.Accessibility.label)) |
| Alert title | .alert(L10n.Common.errorTitle, message: ...) |
This skill provides general best practices for Swift localization. Adapt patterns to your project's specific architecture and requirements.