Jun 5, 2019

Localization Guidelines

Happy WWDC week! Swift 5 has got a new feature named “String Interpolation”, and recently I tried it for type-safe localization. This worked surprisingly well, and all NativeConnect code is localized properly now. This post is a very technical recap with the best practices for the future reference. Maybe it will be useful for your own workflow.

Rules for .strings files

I use dash-separated keys with the following format:

"keychain-error" = "Keychain Error"; // Generic title for Keychain errors
"keychain-error-%@" = "Problem: %@"; // Simple message format for Keychain errors
"keychain-error-%1$@-%2$@" = "%1$@: %2$@"; // Complex message format for Keychain errors
  1. Formatted strings from this file may include only %@ as specifiers.
  2. The specifier %d is allowed only in .stringsdict files.
  3. Just one parameter in the string is represented by standard %@.
  4. Two and more parameters must be defined using an ordered format e.g. %1$@.

Rules for .stringsdict files

This file includes dash-separated keys with the following format:

<key>%d-changes</key>
<dict>
  <key>NSStringLocalizedFormatKey</key>
  <string>%#@format@</string>
  <key>format</key>
  <dict>
    <key>NSStringFormatSpecTypeKey</key>
    <string>NSStringPluralRuleType</string>
    <key>NSStringFormatValueTypeKey</key>
    <string>d</string>
    <key>one</key>
    <string>%d Change</string>
    <key>other</key>
    <string>%d Changes</string>
  </dict>
</dict>
  1. Formatted strings from this file may include only %d as specifiers.
  2. The specifier %@ is allowed only in .strings files.
  3. Just one parameter in the string is represented by standard %d.
  4. Two and more parameters must be defined using an ordered format e.g. %1$d.

Custom API

For Swift code, we will use a helper class named Localizer. This struct is a dumb container for a bundle and a table name, which calls NSLocalizedString. It’s recommended to create and cache some often-used instances per module:

import NativeKit

let errorLocalizer: Localizer = .init(tableName: "Errors")
let defaultLocalizer: Localizer = .init()

The instance of errorLocalizer above is an alias for both Errors.strings and Errors.stringsdict in the main bundle. And the defaultLocalizer is an alias for Localizable.strings plus Localizable.stringsdict. You can use them as follows:

errorLocalizer.stringForKey("keychain-error") // Keychain Error
errorLocalizer.stringWithFormat("keychain-error-\("Item not found.")") // Problem: Item not found.
errorLocalizer.stringWithFormat("keychain-error-\("Accounts")-\("Item not found.")") // Accounts: Item not found.

defaultLocalizer.dictStringWithFormat("\(0)-changes") // No Changes
defaultLocalizer.dictStringWithFormat("\(1)-changes") // 1 Change
defaultLocalizer.dictStringWithFormat("\(5)-changes") // 5 Changes
  1. The method Localizer.stringForKey(_:) accepts only static strings and raises an assertion, when you try to pass a key with a specifier %.
  2. The method Localizer.stringWithFormat(_:) uses string interpolation and accepts only strings as parameters. This is why %d is denied in .strings.
  3. The method Localizer.dictStringWithFormat(_:) uses interpolation as well, but accepts only integers as parameters. This is why %@ is denied in .stringsdict.

Best Practices

Naturally, all user-facing strings, including errors, should be localized.

It is recommended to split all strings by separate files e.g. Localizable.strings and Errors.strings. Strings in these files should be sorted alphabetically by keys.

You should be very careful before adding new keys. Remember about Foundation APIs like NSDateComponentsFormatter.includesApproximationPhrase.

Many keys can be reused multiple times. For this goal, I have a special Standard.strings with generic translations:

"label-name" = "Name"; // Standard label for a Name field
"label-description" = "Description"; // Standard label for a Description field

"action-cancel" = "Cancel"; // Standard title for a Cancel action
"action-save" = "Save"; // Standard title for a Save action

The .stringsdict file with the same name as .strings may be convenient too. Remember an example with %d-changes above? It’s from the Standard.stringsdict.

Exceptions

If you need to pass a float number which is not a string, use a custom formatter. This is a safer and recommended way to handle any data, including numbers and dates. As a workaround, just fallback to String(describing: data).

If you really need both %d and %@ in the key, there is a special method named Localizer.unsafeStringForKey(_:). It will not raise an assertion when you pass a static or even dynamic string with any specifiers.

Thanks for reading!