NSFormatter is an old API riddled with pits to fall into. It’s very expensive to create a formatter (or adjust its configuration) because it needs to load huge tables of data from ICU for different languages.

Contexts, like a SwiftUI View’s body, that are expected to run quickly and may be called multiple times are bad place to create new formatters. We needed a path out of this tangle, and thus the new (ish) FormatStyle APIs were born.

These new APIs are very Swifty, very readable, mostly writeable, and best of all, you don’t have to think about caching or performance anymore.

There are a ton of different ways to configure Swift’s built-in format styles (like, a lot), but these APIs don’t actually cover the breadth of what the old NSFormatters could do. One such number format that doesn’t exist in the new APIs is ordinal number formatting:

let f = NumberFormatter()
f.numberStyle = .ordinal
f.format(3) // => "3rd"

Surprisingly, there’s no way to do this with the new FormatStyle API. Fortunately, it’s pretty straightforward to write a new one. Here’s the basic protocol we have to conform to:

protocol FormatStyle {
    associatedtype Input
    associatedtype Output

    func format(_ value: Input) -> Output
}

For an ordinal formatter, this is straightforward:

struct OrdinalFormatStyle: FormatStyle {
    static let ordinalNumberFormatter = {
        let f = NumberFormatter()
        f.numberStyle = .ordinal
        return f
    }()

    func format(_ value: Int) -> String {
        Self.ordinalNumberFormatter.string(from: .init(value: value)) ?? ""
    }
}

The static number formatter makes sure that we only create one formatter and ensures that we don’t run into the aforementioned expensive construction problems.

A little helper for sugar:

extension FormatStyle where Self == OrdinalFormatStyle {
    static var ordinal: OrdinalFormatStyle {
        .init()
    }
}

And now we can write really simple code for formatting ordinal numbers.

3.formatted(.ordinal) // => 3rd

Seems easy enough, but there’s actually a part of the protocol that I haven’t mentioned yet. There’s a method that lets the caller adjust the locale of the formatting in a fluent way:

extension FormatStyle {
    /// If the format allows selecting a locale, returns a copy of this format with the new locale set. Default implementation returns an unmodified self.
    func locale(_ locale: Locale) -> Self
}

The docs say there’s a default that just returns self. This is why our format style worked without it:

extension OrdinalNumberFormat {
    func locale(_ locale: Locale) -> Self {
        self
    }
}

Ordinal numbers are locale-dependent, so we do want to implement this for completeness. A naive implementation is to just modify our number formatter:

extension OrdinalNumberFormat {
    func locale(_ locale: Locale) -> Self {
        Self.ordinalNumberFormatter.locale = locale
        return self
    }
}

However, this creates a major problem. The static number formatter is shared among all of our OrdinalNumberFormat objects, so changing one will change all of them. This is a problem because FormatStyles are generally value types, and they should have value semantics.

let formatStyle = FormatStyle.ordinal

let frenchOrdinalNumbers = formatStyle.locale(.init(identifier: "fr_FR"))

let portugueseOrdinalNumbers = formatStyle.locale(.init(identifier: "pt_PT"))

But this code doesn’t behave as expected, because of the shared formatter.

3.formatted(frenchOrdinalNumbers) // returns 3ยบ (Portuguese) but should be 3e (French)

We could try moving away from the static number formatter, perhaps making it an instance variable. Sadly, that has two problems. Not only would it make it so we’re constructing an expensive formatter every time we construct a format style, it also wouldn’t really solve the problem, since the number formatter is a reference type. When our format style is copied around, those copies will still share the same reference to the number formatter, and mutating one will still mutate them all. We’d have to implement some kind of complicated copy-on-write using isKnownUniquelyReferenced, and it would be a mess.

Instead, a slightly better approach is to keep track of a pool of number formatters, one per locale (and configuration), grabbing the right one based on the current object’s locale.

To do that, we need to keep track of the locale that the current FormatStyle wants to use, and add the fluent helper to change the locale. This ends up pretty simple because the ordinal format style doesn’t require a configuration, but you could add more configuration information here, as needed.

struct OrdinalFormatStyle: FormatStyle {
    var locale: Locale = .current

    func locale(_ locale: Locale) -> Self {
       .init(locale: locale)
    }
}

Since FormatStyle requires a conformation to the Hashable protocol, we can use its value to look up the right NumberFormatter. We’ll store all of our cached formatters in a shared place, keyed by the format style:

extension OrdinalFormatStyle {
    static var formatters: [OrdinalNumberFormat: NumberFormatter] = [:]
}

We need a good way to get the right formatter, and if we don’t have one, create it:

extension OrdinalFormatStyle {
    static func formatter(for formatStyle: Self) -> NumberFormatter {
        if let formatter = formatters[formatStyle] {
            return formatter
        }

        let f = NumberFormatter()
        f.numberStyle = .ordinal
        f.locale = formatStyle.locale

        formatters[formatStyle] = f

        return f
    }
}

And lastly, we can update the format method to actually grab the right formatter and perform the formatting.

extension OrdinalFormatStyle {
    func format(_ value: Int) -> String {
        Self.formatter(for: self)
            .string(from: .init(value: value)) ?? ""
    }
}

This is great, except for one thing. Value types are meant to be copied as they’re moved around, which means every reference to a format style will have its own copy, which means that they’re inherently thread-safe, since they’re not shared. However, our implementation is not thread-safe, since we’re sharing the formatters dictionary. (NSNumberFormatter itself is thread-safe, but the reference holding the dictionary that holds all the formatters is not.)

There’s an easy fix here, which looks like this:

extension OrdinalFormatStyle {
    nonisolated(unsafe) static var formatters: [Self: NumberFormatter] = [:]
    static let queue = DispatchQueue(label: "com.khanlou.OrdinalFormatStyle")

     static func formatter(for formatStyle: Self) -> NumberFormatter {
        queue.sync {
            // get or create a formatter
        }
     }
}

Done. Swift pushes you towards using values, which are typically value types managed by the system. However, it’s also possible to get the semantics of values even if the represented types under the hood are still reference types. Faking these semantics is very important for ensuring that the model that users have in their minds stays valid, no matter how complex its underlying implementation is.