Foundation re-formatted
Apple developers have long been able to use a rich set of Formatter
objects to ensure that numbers, currencies, dates, measurements, lists, personal names, URLs and so on are correctly formatted for a given locale and context, and to enable parsing such objects from strings. The iOS 15/ macOS 12 release of Foundation however brought with it a whole new paradigm for handling this kind of formatting and parsing. Instead of having to instantiate and manage the lifecyle of aFormatter
class, we can now handle this parsing and formatting via a more declarative API that involves chaining a set of methods on a struct that implements the ParseableFormatStyle
protocol, composing the formatter in a fluent/ builder pattern that is becoming increasingly familiar thanks to its extensive use in SwiftUI. There have been some great overviews of these new APIs. Rather than try to give an overview of all of the new formatting APIs, this blogpost is just going to home in on one use-case where the move from the old object-oriented Formatter
to the new declarative paradigm really shines.
Date-only ISO 8601
Take the use-case of decoding some JSON from a network service which has dates that look like this: "2023-12-25"
. The endpoint in question is a list of public holidays in the UK: https://www.gov.uk/bank-holidays.json. These dates are formatted as ISO-8601, but a subset of the standard that provides only the date, not the time or any timezone information. Although JSONDecoder
has an iso8601
DateDecodingStrategy
, it expects to receive the time as well as the date, and will fail to decode date-only strings.
So, we need to supply a custom DateDecodingStrategy
. JSONDecoder.DateDecodingStrategy
uses a closure-based API for supplying custom date decoding. Let’s start by decoding the date as a string in the custom decoding closure:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let dateString = try String(from: decoder)
...
}
Now we need to parse that string as a date. Let’s first look at how we would have handled this using the old Formatter
API. First we would set up a Formatter
object and set the correct options for the dates we expect to arrive from the endpoint:
let formatter = ISO8601DateFormatter()
formatter.formatOptions = .withFullDate
By replacing the default format options with .withFullDate
, we’ve told the formatter to expect date-only strings.
With this object-based API, we need to think about how to manage the lifecyle of our formatter. We almost certainly don’t want to place these two lines of code inside the custom date decoding block: this block will get called to decode every date in the JSON response, and given that the date formatter ultimately descends from NSObject
, it’s quite an expensive object to be repeatedly instantiating. So we probably want to instantiate it outside the block, and dispose of it once the response has finished decoding:
let formatter = ISO8601DateFormatter()
formatter.formatOptions = .withFullDate
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let dateString = try String(from: decoder)
guard let date = formatter.date(from: dateString) else {
let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Date is not ISO8601")
throw DecodingError.dataCorrupted(context)
}
return date
}
There are a couple of bits of housekeeping we have to deal with here. Firstly, the old Formatter
API doesn’t throw errors, it just returns nil (failing silently in other words). It is up to us then to navigate the DecodingError
API to throw the correct error.
And more worryingly, the compiler complains about the formatter being captured by the closure, with the warning
Capture of ‘formatter’ with non-sendable type ‘ISO8601DateFormatter’ in a `@Sendable` closure
We can silence this warning using @unchecked Sendable
conformance:
extension ISO8601DateFormatter: @unchecked Sendable {}
…but wouldn’t it be nice to not have to worry about managing the lifecycle and thread safety of formatting objects at all?
In with the new
The new formatting APIs can be a little challenging to discover. In place of a set of objects that all descend from Formatter
, the new APIs are more diffuse. For formatting an object as a String
look for .formatted()
methods on Date
, Double
, Measurement
(in iOS 16 also URL
) and so on. For parsing from strings, look for initialisers that take a string followed by a strategy
or format
parameter where you will pass an implementation of the ParseableFormatStyle
protocol (not to be confused with initialisers that take a formatter
argument, these refer to the old Formatter
objects). These parser/ formatter structs are namespaced within the object they parse/ format. In our example, the struct we want is Date.ISO8601FormatStyle
. There is also a static variable .iso8601
that we can use to shorten the full type. So to parse a date string in standard ISO8601, we would use Date(dateString, strategy: .iso8601).
There is a bit of a discoverability issue here. When faced with the Date(_ value:strategy:)
initialiser, in my testing Xcode was unable to infer any code completions for the strategy argument. Working out what to enter for the strategy can be a case of starting with the namespace of the object you’re trying to parse (in this case Date.
), seeing what ParseableFormatStyle
objects are available, and then seeing if convenient factory methods exist or can be created.
Confusingly, some objects have both an initialiser with a strategy
parameter and one with a format
parameter. In my experience it is often more ergonomic to use the format
initialiser, which takes a ParseableFormatStyle
implementation. Take for example wanting to parse currency strings such as "£40.00"
as a Decimal
. Note that this is a conversion that requires both a currency code and a locale. In this case Decimal.ParseStrategy
actually wraps the format style, so we can omit this wrapper by using the format
initialiser. With this initialiser, Xcode can even supply us with code completion suggestions as soon as you enter .
in the format
field:
try Decimal(value, strategy: Decimal.ParseStrategy(format: .localizedCurrency(code: "GBP", locale: .enGB)))
// omiting the `Decimal.ParseStrategy` wrapper:
try Decimal(value, format: .localizedCurrency(code: "GBP", locale: .enGB))
The available formatters aren’t always consistent. For instance, if we were wanting to store our currency as a Double
instead of a Decimal
, FloatingPointFormatStyle
does not have a localizedCurrency
factory method, so we would instead chain together a currency
and a locale
call:
try Double(value, format: .currency(code: "GBP").locale(.enGB))
If we wanted to have a consistent call-site with the Decimal
one, it isn’t too hard to define a static factory method in an extension:
extension FloatingPointFormatStyle.Currency {
static func localizedCurrency(code: String, locale: Locale) -> Self {
.currency(code: code).locale(locale)
}
}
// call site:
try Double(value, format: .localizedCurrency(code: "GBP", locale: .enGB))
This feature demonstrates the fluent/ builder pattern of the new API. Methods return a modified instance of the same struct, so that we can compose a formatter by chaining several methods together. We’ll now turn to another example of this pattern in the final section of this post.
Foundation Re-formatted
Returning to our use case of the date-only API, in order to replace the default ISO8601 implementation with one that is date only, we can chain together a string of modifiers on the base ISO 8601 formatter: .iso8601.year().month().day()
. These modifiers overwrite the default set of components vended by the ISO 8601 formatter, indicating that we are only interested in year, month and day. Our custom decoder now looks like this:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let dateString = try String(from: decoder)
return try Date(dateString, strategy: .iso8601.year().month().day())
}
We no longer have to manage the lifecycle of a Formatter
object, ensure its threadsafety, or manually compose a DecodingError
. To make this custom decoder easy to use wherever I need to interact with this API in my code, I like to add a convenience initialiser to JSONDecoder
that takes a DateDecodingStrategy
and a factory method that returns our date-only ISO8601 decoder:
extension JSONDecoder {
convenience init(dateDecodingStrategy: DateDecodingStrategy) {
self.init()
self.dateDecodingStrategy = dateDecodingStrategy
}
static var iso8601DateOnly = JSONDecoder(dateDecodingStrategy: .custom { decoder in
let dateString = try String(from: decoder)
return try Date(dateString, strategy: .iso8601.year().month().day())
})
}
The new APIs, particularly when it comes to parsing strings into data, are more diffuse than the Object-Oriented APIs they replace (where everything descends from the Formatter
base class). They don’t always feel consistent at the call site. Date
only gives us an initialiser that takes a strategy
, whereas number types like Decimal
give us the choice of strategy
and format
, with the latter being more ergonomic. As we have seen, different types of ParseableFormatStyle
implementations on number types handle localization with slightly different calls. But with certain use cases, like custom JSON decoding, the advantages of moving from an Object-Oriented to a declarative struct-based paradigm are clear.
Because the new API is struct based we no longer need to worry about the lifecycle of objects, or whether they conform to Sendable
and will play nice with async/ await code. And because the new API throws errors instead of silently failing, we don’t have to worry about composing errors like DecodingError
by hand.
In this post we’ve focused on one specific use-case, custom JSON decoders, where taking object lifecycle management out of the equation makes developers’ lives easier. But as Apple increasingly moves away from its Object-Oriented legacy towards declarative and composable APIs (I’m thinking particularly of SwiftUI) there are going to be a growing number of domains where being able to parse and format data without the developer overhead of managing the lifecycles and thread-safety of the formatting objects will pay real dividends.