Articles, podcasts and news about Swift development, by John Sundell.

Formatting numbers in Swift

Published on 01 Nov 2020

A significant part of any given app’s logic is likely going to involve working with numbers in one way or another. Whether it’s in order to perform layout calculations, to schedule events using time intervals, or by dealing with our own, custom metrics, numbers really are everywhere.

While working with numbers is one of those things that computers are inherently good at, we also occasionally need to format and present some of our numbers in a human-readable way, which can often be tricker than expected. So this week, let’s explore that topic, and how different kinds of numbers might warrant different formatting strategies.

Solving the decimal problem

At the most basic level, creating a textual representation of a given number simply involves initializing a String with it, which can either be done directly, or by using a string literal:

let a = String(42) // "42"
let b = String(3.14) // "3.14"
let c = "\(42), \(3.14)" // "42, 3.14"

However, while that approach might work well for generating simpler descriptions of numbers that are under our complete control, we’ll likely going to need much more robust formatting strategies when dealing with dynamic numbers.

For example, here we’ve defined a Metric type that lets us associate a given Double with a name, which we then use when generating a custom description for such a value:

struct Metric: Codable {
    var name: String
    var value: Double
}

extension Metric: CustomStringConvertible {
    var description: String {
        "\(name): \(value)"
    }
}

Since the above Metric type can contain any Double value, we probably want to format it in a more predictable way. For example, rather than simply converting its Double into a String, we could use a custom format to round it to two decimal places, which will make our output consistent regardless of how precise each underlying Double value actually is:

extension Metric: CustomStringConvertible {
    var description: String {
        let formattedValue = String(format: "%.2f", value)
        return "\(name): \(formattedValue)"
    }
}

However, we’ll now always output two decimal places, even when our Double is a whole number, or if it just has a single decimal place — which might not be what we were looking for. Take the number 42 for example. We probably don’t want it to be formatted as 42.00, which is what will happen with our current implementation.

An initial idea on how to solve that problem might be to go for the manual approach, and trim all trailing zeros and decimal points from our formatted string before returning it — like this:

extension Metric: CustomStringConvertible {
    var description: String {
        var formattedValue = String(format: "%.2f", value)

        while formattedValue.last == "0" {
            formattedValue.removeLast()
        }

        if formattedValue.last == "." {
            formattedValue.removeLast()
        }

        return "\(name): \(formattedValue)"
    }
}

The above code certainly works, but it’s arguably not very elegant, and also makes the assumption that we’ll always format each of our numbers the same way for all users — which we might not actually want to do. Because it turns out that although mathematics might be a truly universal concept, the way people expect numbers to be represented in text varies quite a lot between different countries and locales.

Using NumberFormatter

Instead, let’s use Foundation’s NumberFormatter to solve our decimal problem. Just like how a DateFormatter can be used to format Date values in various ways, the NumberFormatter class ships with a quite comprehensive suite of formatting tools that are all specific to numbers.

For example, using NumberFormatter, we can specify that we want to format each number as a decimal with a maximum of two fraction digits, which will give us our desired result without having to do any manual adjustments. Numbers like 42, 42.1 and 42.12 will now all be rendered just like that, and any number that’s more precise will still be automatically rounded to two decimal points:

extension Metric: CustomStringConvertible {
    var description: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2

        let number = NSNumber(value: value)
        let formattedValue = formatter.string(from: number)!
        return "\(name): \(formattedValue)"
    }
}

We can safely force-unwrap the optional that NumberFormatter returns from the above call, since we’re in complete control over the NSNumber that is being passed into it.

Another major benefit of using NumberFormatter is that it’ll automatically take the user’s current Locale into account when formatting our numbers. For instance, in some countries the number 50932.52 is expected to be formatted as 50 932,52, while other locales prefer 50,932.52 instead. All of those complexities are now handled for us completely automatically, which is most likely what we want when formatting user-facing numbers.

However, when that’s not the case, and we’re instead looking for consistency across all locales, then we could either assign a specific Locale to our NumberFormatter, or we could configure it to use specific characters as its decimalSeparator and groupingSeparator — like this:

extension Metric: CustomStringConvertible {
    var description: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2
        formatter.decimalSeparator = "."
formatter.groupingSeparator = ""

        ...
    }
}

Worth noting is that we can also produce localized numbers when using String directly, by passing a specific locale (or .current) to the format-based initializer that we used earlier.

In this case, let’s say that we do want our formatting to be localized. To finish our implementation, let’s move the creation of our NumberFormatter to a static property (which will let us reuse the same instance across all Metric values), and let’s also introduce a dedicated API for retrieving each formatted value by itself — like this:

extension Metric: CustomStringConvertible {
    private static var valueFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2
        return formatter
    }()

    var formattedValue: String {
        let number = NSNumber(value: value)
        return Self.valueFormatter.string(from: number)!
    }

    var description: String {
        "\(name): \(formattedValue)"
    }
}

So NumberFormatter is incredibly useful when we wish to format a raw numeric value into a human-readable description, but it can also do much more than just that. Let’s continue exploring!

Domain-specific numbers

Depending on what kind of app that we’re working on, chances are that we’ll also have to deal with numbers that are domain-specific. That is, they represent something more than just a raw numeric value.

For example, let’s say that we’re working on a shopping app, and that we’re using a Double wrapped in a custom Price struct to describe a given product’s price:

struct Product: Codable {
    var name: String
    var price: Price
    ...
}

struct Price: Codable {
    var amount: Double
    var currency: Currency
}

enum Currency: String, Codable {
    case eur
    case usd
    case sek
    case pln
    ...
}

Now the question is, how to format such a Price instance in a way that makes sense to each of our users, regardless of which country that they’re in and what locale that they’re using?

This is another type of situation in which NumberFormatter can be incredibly useful, as it also includes full support for localized currency formatting. All that we have to do is to set its numberStyle to currency and give it the code of the currency that we’re using — like this:

extension Price: CustomStringConvertible {
    var description: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = currency.rawValue
        formatter.maximumFractionDigits = 2

        let number = NSNumber(value: amount)
        return formatter.string(from: number)!
    }
}

For example, here’s how a price of 3.14 in the Swedish currency SEK will be displayed in a few different locales when using the above approach:

Those might seem like minor differences in the grand scheme of things, but making the way we format prices and other numbers seem completely natural to each user can really make an app feel much more polished. Of course, the next step would be to also automatically convert each price into the current user’s own currency, but that’s definitely out of scope for this particular article.

Besides prices, another common category of numeric values that we likely want to localize is measurements. For example, let’s say that the imaginary shopping app that we were just working on has now pivoted into being solely focused on selling vehicles, and that we’ve converted our Product type into a more specific Vehicle model, which includes properties such as topSpeed:

struct Vehicle {
    var name: String
    var price: Price
    var topSpeed: Double
    ...
}

Currently, our topSpeed property is once again a Double, and while that’s certainly a great choice for most raw numbers that should have floating-point precision, it’s actually not a great fit in this case — since our current implementation doesn’t tell us anything about what unit of measurement that our value is using. It could be kilometers per hour, miles per hour, meters per second, and so on.

Expressing that kind of unit-based numeric values is exactly what the built-in Measurement type is for, so let’s use that instead. In this case, we’ll specialize it with the phantom type UnitSpeed, which makes it crystal clear that our topSpeed value represents a measurement of speed:

struct Vehicle {
    var name: String
    var price: Price
    var topSpeed: Measurement<UnitSpeed>
    ...
}

When creating instances of the above Vehicle type, we’ll now be required to always specify the underlying unit of measurement for our topSpeed property, which is a great thing, as that significantly educes the ambiguity of those values. But that’s just the beginning, because Measurement also has its very own formatter, which we can now use to easily generate formatted descriptions of each vehicle’s top speed:

extension Vehicle {
    var formattedTopSpeed: String {
        let formatter = MeasurementFormatter()
        return formatter.string(from: topSpeed)
    }
}

What’s really great is that not only will the above description be localized, MeasurementFormatter will also automatically convert each value into the unit preferred by the current user’s locale — which will be either km/h or mph in this case. Really cool!

However, there’s one thing that we need to keep in mind when using Measurement values, and that’s how they’re encoded and decoded by default. When using compiler-generated Codable conformances, each Measurement value is expected to be decoded from a dictionary containing several metadata properties that are probably not included in any JSON that we’re downloading from our app’s server. Instead, we most likely have an agreed-upon unit of measurement that our server is using, meaning that we’d have have to perform our decoding manually in this case — for example like this:

extension Vehicle: Codable {
    private enum CodingKeys: CodingKey {
        case name, price, topSpeed, ...
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        // Decoding all other properties
        ...

        topSpeed = try Measurement(
            value: container.decode(Double.self, forKey: .topSpeed),
            unit: .kilometersPerHour
        )
    }
    
    // Encoding implementation
    ...
}

Since custom Codable implementations are often quite cumbersome to maintain, let’s also explore an alternative approach. Here’s how we could create a dedicated property wrapper that lets us encapsulate the conversions between Double and Measurement within a single type:

@propertyWrapper
struct KilometersPerHour {
    var wrappedValue: Measurement<UnitSpeed>
}

extension KilometersPerHour: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let rawValue = try container.decode(Double.self)

        wrappedValue = Measurement(
            value: rawValue,
            unit: .kilometersPerHour
        )
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrappedValue.value)
    }
}

A major benefit of the above approach is that, unless we really need Vehicle to use a custom Codable implementation, we can now simply mark our topSpeed property with @KilometersPerHour and we’ll once again be able to let the compiler generate all of that code for us:

struct Vehicle: Codable {
    var name: String
    var price: Price
    @KilometersPerHour var topSpeed: Measurement<UnitSpeed>
    ...
}

For more on using property wrappers to customize Codable on a per-property basis, check out “Annotating properties with default decoding values”.

With the above in place, we’ll now get all of the advantages of using Measurement — from the additional type safety, to the built-in formatting and conversion features — while still being able to use Double values when encoding and decoding our models.

Conclusion

The task of formatting numbers into human-readable strings is most likely something that we want to delegate to the system as much as possible, especially when we wish to produce descriptions that are localized and otherwise adapted to the current user’s locale. Because simply turning a Double into a String might be a trivial task, but actually formatting each value into a correct string is often much more difficult than what it initially might seem like.

Got questions, comments or feedback? I’d love to hear from you, either via Twitter or email.

Thanks for reading! 🚀