All you need to know about localizing plurals

Theory behind Types and Categories - CLDR

Apple, like Google and Microsoft, use Unicode's Common Locale Data Repository, or CLDR for short, through ICU library. What's that? It's the most extensive standardized collection of locale data in the world, that together with libraries like ICU, allow software to understand languages' conventions for dealing with things like formatting time or numbers and capitalization, and rules for spelling out numbers, and plural cases and so much more. Since plethora of platforms use it, familiarizing yourself with it is a worthy investment of your time.

Different languages vary a lot in how they handle plurals. Each language might handle, but doesn't have to (some languages don't have such concepts 🤯), different Types of plurals:

In each type a string can have one or more forms, depending on the number. For example, in English we can say:

In cardinals, since 2, 3, 21 and 0 result in the same form, CDLR groups them in one category. There's always a unique category for each unique form. Therefore 1, despite being the only number, which results in "bug" form, gets a category for itself too.

If we look into ordinals, to support our string, we need 4 categories - one for 1st and 21st, one for 2nd, one for 3rd and 933rd, and one for everything else that has "th" suffix.

It's not strictly about the nous, it's about the whole text being translated, so even if the noun is invariant like fish, we need two categories to support following messages:

Categories are language specific and don't have meaning. Some languages have different versions for every category: zero, one, two, few, many, other, some have only for some.

Okay, but how do I determine which category to use for given number? Fortunately you don't have to, CLDR defines machine understandable set of rules for every category to determine which one to use. From programmers perspective, all that's required is to pass a number to right function in Swift and it will figure out which category to use.

It's astounding how different the languages are, just take a look at this table defining all categories for every language and their respective rules.

Applying theory to .stringsdict

In order to support plurals, instead of using Localizable.strings, we need to create a new file with .stringsdict extension by selecting File > New > File... and selecting Stringsdict File from Resources category. Let's take a look at what's inside. Be sure to open the file as source code by right clicking, Open As > Source Code.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>StringKey</key>
	<dict>
		<key>NSStringLocalizedFormatKey</key>
		<string>%#@VARIABLE@</string>
		<key>VARIABLE</key>
		<dict>
			<key>NSStringFormatSpecTypeKey</key>
			<string>NSStringPluralRuleType</string>
			<key>NSStringFormatValueTypeKey</key>
			<string></string>
			<key>zero</key>
			<string></string>
			<key>one</key>
			<string></string>
			<key>two</key>
			<string></string>
			<key>few</key>
			<string></string>
			<key>many</key>
			<string></string>
			<key>other</key>
			<string></string>
		</dict>
	</dict>
</dict>
</plist>

.stringsdict is nothing more than a Property List. Let's try to deconstruct it step by step. Inside the plist there's a single dictionary containing a single key-value pair.

<plist version="1.0">
<dict>
	<key>StringKey</key>
	<dict>
		...
	</dict>

The key (StringKey) is what we pass to either NSLocalizedString macro or SwiftUI's Text struct to reference this string.

The value for above mentioned key is a dictionary containing two other pairs. First one's key is NSStringLocalizedFormatKey under which we're required to specify a formatted string containing variable or variables. Definitions of those variables make up the rest of the key-value pairs in the dictionary. So if our translated sentence would contain 2 number dependent forms we'd have total of 3 key-value pairs. The placeholder has 2 pairs since it only contains one variable form.

<key>NSStringLocalizedFormatKey</key>
<string>%#@VARIABLE@</string>
<key>VARIABLE</key>
<dict>
	<key>NSStringFormatSpecTypeKey</key>
	<string>NSStringPluralRuleType</string>
	<key>NSStringFormatValueTypeKey</key>
	<string></string>
	<key>zero</key>
	...

It's important to note that just like when interpolating a String in Swift, we must mark the interpolation with %#@ and @ tokens at the beginning and the end respectively. In the autogenerated placeholder, the variable is whole text, but that's rarely a case - it's easier to grasp the concept on example:

<key>NSStringLocalizedFormatKey</key>
<string>You've applied to %#@jobOfferCount@.</string>
<key>jobOfferCount</key>
<dict>
	...
	<key>one</key>
	<string>one company</string>
	<key>other</key>
	<string>%d companies</string>

It's completely valid for the localized string to be dependent on more than 1 variable:

<key>NSStringLocalizedFormatKey</key>
<string>You've applied to %#@appliedCount@. %#@processingCount@ still reviewing your application.</string>
<key>jobOfferCount</key>
<dict>
	...
	<key>one</key>
	<string>one company</string>
	<key>other</key>
	<string>%d companies</string>
</dict>
<key>processingCount</key>
<dict>
	...
	<key>one</key>
	<string>One company is</string>
	<key>other</key>
	<string>%d companies are</string>
</dict>

The only pice we haven't touched yet is the dictionary defining different forms of a variable, which is the value for every variable.

It must contain NSStringFormatSpecTypeKey key, who's only possible value is NSStringPluralRuleType, as well as NSStringFormatValueTypeKey key specifying variable's format.

Format specifier must be in accordance to IEEE's printf standard, summarized here. Just like the one we use in NSString.

The rest of the key-value pairs, consist of key specifying category according to CDLR and formatted string to use. If you want to embed the variable in the string, use the format specifier, in the formatted string.

It's absolutely crucial for the format specifier, declared as key in top level dictionary in .stringsdict, to match the type of the variable being interpolated. I've seen few StackOverflow questions asking why there wasn't a match between what they put into Text initializer and localization key, while they had %d in localization key and were interpolating with an Int. As described in Apple's docs, %d is a specifier for 32bit unsigned integer, while Int is 64bit on 64bit platforms. The solution is to correct the format specifier to %lld.

Caller side explained on example - SwiftUI

It's time to get our hands dirty with some code. For sake of example let's assume we have the following in Localizable.stringsdict and the file is in the Bundle.main:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>You've bought %lld apples.</key>
	<dict>
		<key>NSStringLocalizedFormatKey</key>
		<string>You've bought %#@appleCount@.</string>
		<key>appleCount</key>
		<dict>
			<key>NSStringFormatSpecTypeKey</key>
			<string>NSStringPluralRuleType</string>
			<key>NSStringFormatValueTypeKey</key>
			<string>d</string>
			<key>one</key>
			<string>a single apple</string>
			<key>other</key>
			<string>%d apples</string>
		</dict>
	</dict>
</dict>
</plist>

SwiftUI's Text struct has very smart trick up its sleeve - the default initializer actually takes LocalizedStringKey instead of a String.

init(_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil)

It comes helpful in our usecase as well. As long as we specify a string literal, or something that's convertible to it, it will look up if there's a matching localization key and return localized version. Thanks to ExpressibleByStringInterpolation, all we need to show the localized string and fill it out, is to match the key by interpolating it with a variable.

struct ContentView: View {

    @State var count: Int = 1

    var body: some View {
        VStack {
            Text("You've bought \(count) apples.")
            Stepper(value: $count) {
                Text("Want more apples?")
            }
        }
    }
}

And that's it. It also works if you interpolate the key multiple times.

The same example, in UIKit

Unfortunately, when using UIKit's way of localizing strings - NSLocalizedString - we can't use string interpolation. In practice it means we have to do two things:

  1. Get a localized string by calling NSLocalizedString and matching the key,
  2. Fill out the variables with actual data
let count = Int(1)
let localized: String = NSLocalizedString("You've bought %lld apples.", comment: nil)
myLabel.text = String(format: localized, count)

If you don't provide required category

It's really important to check if translations have all of the categories required by given language. If you miss one you might end up with (null) it the place of the variable. Unfortunately it won't be caught by plutil -lint linter. Always refer to Unicode's table.

Closing words

Even though the structure of .stringsdict appears scary, I hope you're able to understand it now. Especially with SwiftUI, localizing plurals at the caller side isn't that different than regular strings. As always, if you have any questions related to the subject, feel free to Tweet at me.


twitter logoDo you find this article insightful? Let others know!