Formatting numbers in Swift
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:
- Sweden:
3,14 kr
- Spain:
3.14 SEK
- US:
SEK 3.14
- France:
SEK 3,14
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! 🚀