Foundation re-formatted

Oliver Dew
Kin + Carta Created
7 min readMar 17, 2023
Photo credit: Karol D on Pexels

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.

--

--