Parsing in Swift: a DTO-based Approach

Also, a comparison with the Codable approach

Luis Recuenco
Better Programming

--

Photo by Thomas Ashlock on Unsplash

Turning JSON into actual Swift types is something most apps have to deal with eventually. At the beginning of Swift, Apple was not opinionated about how we should approach that task. So people came up with a bunch of different libraries to do so. Some of them are quite esoteric. Fortunately, Apple came up with its own first-party solution, Codable.

Turning JSON into Swift types can be as easy as conforming those types to the Codable protocol. You might not need to do anything else. The compiler will do its magic and synthesize the appropriate parsing code for you. That’s lovely. There’s no better code than the one you don’t have to write. Sometimes, the “parsing code” you have to write is minimal, providing a CodingKeys enum with the appropriate mapping between your type property names and the JSON keys.

Unfortunately, that’s where the magic ends. The honeymoon with Codable tends to end pretty soon. The more complex, statically safe, and correct our types are, the more impedance we have between the JSON and our types, and the more likely it is that we’ll end up writing quite a lot of complex parsing code inside init(from decoder:) throws.

I still remember my days writing Objective-C. Swift has definitely made me a better developer. And one of the main reasons is that I now tend to think much more deeply about my data, trying to encapsulate all the correct semantics and invariants into that domain model. I tend to think about data first. Objective-C lacked a lot in that regard. Luckily, Swift has all the proper primitives to model our domain correctly: optionals, generics, powerful enums, values types…

While it can be tempting to couple our models to the JSON shape and let Codable do its magic to avoid boilerplate, I do think we should think about the correct data first, and then interpret the JSON correctly via the (JSON) -> Model function. The inherently dynamic nature of JSON leaking into our domain model will likely make it “worse”.

The JSON -> Model function

I tend to see this function as a kind of anticorruption layer. The sooner we validate the JSON and turn it into the correct types inside our domain, the better. That validation can vary greatly depending on how much control we have over the API.

  • On one side of the spectrum, we have a specifically-tuned API via a Backend for Frontend, where the schema contract has been thoroughly thought out and agreed upon amongst Mobile and API developers. Both API and the Mobile app will evolve hand in hand.
  • On the other side, we need to communicate with a third-party API we don’t control.

On either side of the spectrum, the custom parsing/validation code we end up writing is very different. The third-party API will likely require more “validation” to fit our model.

We have two places to put that parsing code:

  1. Use Codable and implement init(from decoder:) throws.
  2. Use some kind of intermediate “data layer” model (let’s call this DTO — Data Transfer Object) and have an extra function (DTO) -> Model throws for that transformation.

I guess the word DTO doesn’t feel as idiomatic in Swift as it might be in other languages like Java or C#. And while I agree that the idiomatic way to approach parsing in Swift is to go the Codable path, it is good sometimes to consider other alternatives and see if they solve the problem better (or worse).

This article doesn’t try to convince anyone that the DTO approach is a better one (in fact, I don’t think it is for a lot of cases). It only tries to show a “different approach”. A new tool and alternative to that parsing code we are writing inside init(from decoder:) throws. Sometimes, it might simplify things. Some other times, it may not.

My humble opinion is that writing custom Codable code is not only boilerplate, but also hard. There are quite a few new “idioms” to learn about keyed containers, unkeyed ones, coding keys, etc.

Any time we have to deal with some “special” case (such as not failing if an element of an array fails to decode, or unknown cases for enums), we end up having to implement init(from decoder:) throws and writing quite a gnarly parsing code.

While some of that pain can be eased by using property wrappers, the complexity is still there. We only moved it (even coupling ourselves to a third-party library along the way).

A Richer Domain

Let’s suppose we are trying to transform the following JSON into the correct type.

let json = """
{
"name": "Luis",
"twitter": "@luisrecuenco"
}
"""

Our first approach could be as simple as this:

struct Person: Decodable {
var name: String
var twitter: String
}

We can even have twitter: String? and be a little more flexible if the twitter key is not always present.

But wouldn’t it be great to have a Twitter type?

First, to validate the username handler correctly and second, to encapsulate all future business logic related to Twitter (I wrote a lot more about this and refinement types in general in a previous article called Value integrity in Swift). Let’s change the code to do that.

struct Person: Decodable {
var name: String
var twitter: Twitter
}

struct Twitter: Decodable {
var handler: String
}

This doesn’t work, as expected. The Twitter type expects to be decoded by a keyed container with the handler key, but we get a single-value container.

Fortunately, the solution is as easy as making the Twitter type conform to RawRepresentable. Let’s do that.

struct Twitter: RawRepresentable, Decodable {
var rawValue: String

init?(rawValue: String) {
self.rawValue = rawValue
}
}

This is “ok”. We are forced to couple our Twitter type with RawRepresentable, so we are forced to use a specific property name, expose rawValue with the same visibility as the Twitter type, etc.

Let’s now try to use validation. If we receive a Twitter handler without the @, we fail.

struct Person: Decodable {
var name: String
var twitter: Twitter?
}

struct Twitter: RawRepresentable, Decodable {
var rawValue: String

init?(rawValue: String) {
guard rawValue.first == "@" else { return nil }
self.rawValue = rawValue
}
}

This code works as long as:

  • We receive a correct Twitter handler with @.
  • We don’t receive any Twitter on the JSON.

But, as soon as we receive an invalid Twitter handler (so the validation fails), the whole decoding process fails as well. This forces us to implement the parsing code manually:

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
twitter = try container.decodeIfPresent(String.self, forKey: .twitter).flatMap(Twitter.init)
}

This kind of process happens a lot with Codable.

Coupling our domain with Codable damages refactoring. It’s very likely that any refactor in our domain breaks the underlying decoding without the compiler telling anything (having to rely on tests to catch those issues).

Those new types, as long as they conform to Decodable, will make the compiler synthesize “new decoding magic”, which will likely crash at runtime.

While implementing init(from decoder:) throws fixes that issue (the compiler will now force all properties to be correctly set), sometimes it’s better to have a proper separation between “the schema types” and the “domain types”.

Let’s now take a look at another alternative, where Person.DTO is our “schema type”, matching the JSON, whereas Person and Twitter are plain structs.

struct Person {
var name: String
var twitter: Twitter?
}

struct Twitter {
var handler: String

init?(handler: String) {
guard handler.first == "@" else { return nil }
self.handler = handler
}
}

extension Person {
struct DTO: Decodable {
var name: String
var twitter: String?
}

init(from dto: DTO) {
name = dto.name
twitter = dto.twitter.flatMap(Twitter.init)
}
}

When we change the shape of our domain, the compiler will force us to update the init(from dto:) function, which is exactly what we want. As long as Person.DTO structure doesn’t change (keeping the JSON schema contract), we are good, and the compiler will help with refactoring.

But using a DTO is not always better. As always, it depends. So let’s analyze both approaches a little bit more thoroughly.

Comparing the Codable vs. DTO approaches

The other day I was watching this talk by Ben Scheirman. It’s a great talk, you should watch it. In that talk, Ben presents three different use cases, and we’ll use those to see how the DTO approach compares.

1. Nested JSON to flatten type

The goal is to turn the following JSON payload into the Beer type.

{
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}

struct Beer {
var id: Int
var name: String
var breweryId: String
var breweryName: String
}

First, let’s have the Codable approach (the one shown in the talk):

let json = """
{
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}
""".data(using: .utf8)!

struct Beer: Decodable {
var id: Int
var name: String
var breweryId: String
var breweryName: String

enum CodingKeys: String, CodingKey {
case id
case name
case brewery
}

enum BreweryCodingKeys: String, CodingKey {
case id
case name
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)

let breweryContainer = try container.nestedContainer(keyedBy: BreweryCodingKeys.self, forKey: .brewery)
breweryId = try breweryContainer.decode(String.self, forKey: .id)
breweryName = try breweryContainer.decode(String.self, forKey: .name)
}
}

let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)

Before digging into the DTO approach, let’s have some minimal infrastructure code to simplify things. We’ll define what an object that can be decoded from a DTO looks like and how to decode it easily.

protocol DecodableFromDTO {
associatedtype DTO: Decodable
init(from dto: DTO) throws
}

extension JSONDecoder {
func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : DecodableFromDTO {
try T(from: decode(T.DTO.self, from: data))
}
}

With that in place, we can have this:

let json = """
{
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}
""".data(using: .utf8)!

struct Beer {
var id: Int
var name: String
var breweryId: String
var breweryName: String
}

extension Beer: DecodableFromDTO {
struct DTO: Decodable {
struct Brewery: Decodable {
var id: String
var name: String
}
var id: Int
var name: String
var brewery: Brewery
}

init(from dto: DTO) throws {
id = dto.id
name = dto.name
breweryId = dto.brewery.id
breweryName = dto.brewery.name
}
}

let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)

I guess both approaches are quite similar. Let’s first analyze the Signal-to-noise ratio, understanding noise as all the parsing lines of code.

  • Codable SNR = 41/20 ~ 48% of noise
  • DTO SNR = 39/18 ~ 46% of noise

As we can see, the noise we get is quite similar.

Another interesting thing to consider is the impact of changes. How many places do we have to update for any change in the JSON/model?

  • Codable: update CodingKeys and init(from decoder:) throws.
  • DTO: update DTO struct and init(from dto:) throws.

Again, similar results. We can interpret this in two ways:

  • The DTO approach is not worth it, as we are not really getting many advantages from it.
  • The DTO approach, which usually is considered as adding more boilerplate, extra DTO type, etc, is not really much worse than the Codable approach.

Deciding which code is simpler is quite subjective (as many things in programming, unfortunately). Some people might write parsing code inside init(from decoder:) throws very naturally, and Codable containers are second-nature for them.

Some other people might find the extra DTO + (DTO) -> Model function as a simpler approach. I tend to favor the DTO approach slightly in this case. As I mentioned previously, having the schema types decoupled from my domain types allows me to evolve my domain more safely and rapidly.

2. Flat JSON to nested type

The goal is to turn the following JSON payload into the Beer and Brewery types:

{
"id": 123,
"name": "Endeavor",
"brewery_id": "sa001",
"brewery_name": "Saint Arnold"
}

struct Beer {
var id: Int
var name: String
var brewery: Brewery
}

struct Brewery {
var id: String
var name: String
}

As before, let’s go with the Codable approach first:

let json = """
{
"id": 123,
"name": "Endeavor",
"brewery_id": "sa001",
"brewery_name": "Saint Arnold"
}
""".data(using: .utf8)!

struct Beer: Decodable {
var id: Int
var name: String
var brewery: Brewery

enum CodingKeys: String, CodingKey {
case id
case name
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
brewery = try Brewery(from: decoder)
}
}

struct Brewery: Decodable {
var id: String
var name: String

enum CodingKeys: String, CodingKey {
case id = "brewery_id"
case name = "brewery_name"
}
}

let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)

Now, the DTO-based approach:

let json = """
{
"id": 123,
"name": "Endeavor",
"brewery_id": "sa001",
"brewery_name": "Saint Arnold"
}
""".data(using: .utf8)!

struct Beer {
var id: Int
var name: String
var brewery: Brewery
}

struct Brewery {
var id: String
var name: String
}

extension Beer: DecodableFromDTO {
struct DTO: Decodable {
var id: Int
var name: String
var brewery_id: String
var brewery_name: String
}

init(from dto: DTO) {
id = dto.id
name = dto.name
brewery = Brewery(id: dto.brewery_id, name: dto.brewery_name)
}
}

let beer = try! JSONDecoder().decode(Beer.self, from: json)
dump(beer)

SNR:

  • Codable SNR = 39/16 ~ 41% of noise
  • DTO SNR = 37/14 ~ 37% of noise

Impact of changes:

  • Codable: update Beer‘s CodingKeys and init(from decoder:) throws. Update Brewery‘s CodingKeys and maybe, implement init(from decoder:) throws there.
  • DTO: update DTO struct and init(from dto:) throws.

The DTO approach seems slightly better, especially because the changes we’d have to make in case of JSON/model updates are a bit better scoped and less scattered. But the advantages are minor, really.

3. Heterogeneous arrays

Let’s now have a much more interesting example. The goal is to turn the following JSON payload into the Feed type.

{
"items": [
{
"type": "text",
"id": 55,
"text": "This is a text feed item"
},
{
"type": "image",
"id": 56,
"image_url": "http://placekitten.com/200/300"
}
]
}

struct Feed {
let items: [FeedItem]
}

class FeedItem {
let id: Int
}

class TextFeedItem: FeedItem {
let text: String
}

class ImageFeedItem: FeedItem {
let imageUrl: URL
}

Let’s go with the Codable approach first.

let json = """
{
"items": [
{
"type": "text",
"id": 55,
"text": "This is a text feed item"
},
{
"type": "image",
"id": 56,
"image_url": "http://placekitten.com/200/300"
}
]
}
""".data(using: .utf8)!

struct AnyCodingKey: CodingKey {
let stringValue: String

init?(stringValue: String) {
self.stringValue = stringValue
}

var intValue: Int?

init?(intValue: Int) {
fatalError()
}
}

extension AnyCodingKey: ExpressibleByStringLiteral {
init(stringLiteral value: StringLiteralType) {
self.init(stringValue: value)!
}
}

class FeedItem: Decodable {
let id: Int

init(id: Int) {
self.id = id
}
}

class TextFeedItem: FeedItem {
let text: String

init(text: String, id: Int) {
self.text = text
super.init(id: id)
}

enum CodingKeys: String, CodingKey {
case text
}

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
text = try container.decode(String.self, forKey: .text)
try super.init(from: decoder)
}
}

class ImageFeedItem: FeedItem {
let url: URL

init(url: URL, id: Int) {
self.url = url
super.init(id: id)
}

enum CodingKeys: String, CodingKey {
case imageUrl = "image_url"
}

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
url = try container.decode(URL.self, forKey: .imageUrl)
try super.init(from: decoder)
}
}

struct Feed: Decodable {
let items: [FeedItem]

enum CodingKeys: String, CodingKey {
case items
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
var itemsContainer = try container.nestedUnkeyedContainer(forKey: .items)
var itemsContainerCopy = itemsContainer
var items: [FeedItem] = []
while !itemsContainer.isAtEnd {
let typeContainer = try itemsContainer.nestedContainer(keyedBy: AnyCodingKey.self)
let type = try typeContainer.decode(String.self, forKey: "type")
let feedItem: FeedItem
switch type {
case "text":
feedItem = try itemsContainerCopy.decode(TextFeedItem.self)
case "image":
feedItem = try itemsContainerCopy.decode(ImageFeedItem.self)
default:
fatalError()
}
items.append(feedItem)
}
self.items = items
}
}

let feed = try! JSONDecoder().decode(Feed.self, from: json)
dump(feed)

In this case, the DTO approach is trickier. In the previous examples, the JSON schema contract was clear, and we could create the correct DTO type that matched that schema.

But now, the schema depends on the type key. If "type" = "text", we get a different payload than when "type" = "image". To fit this dynamic nature into our DTO-based approach, we are going to leverage a JSON type I implemented in a previous article called Statically-typed JSON payload in Swift.

enum JSON: Codable {
case int(Int)
case string(String)
case bool(Bool)
case double(Double)
case array([JSON])
case dictionary([String: JSON])

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .int(let int): try container.encode(int)
case .double(let double): try container.encode(double)
case .string(let string): try container.encode(string)
case .bool(let bool): try container.encode(bool)
case .array(let array): try container.encode(array)
case .dictionary(let dictionary): try container.encode(dictionary)
}
}

init(from decoder: Decoder) throws {
var container = try decoder.singleValueContainer()
do { self = .int(try container.decode(Int.self)) } catch {
do { self = .string(try container.decode(String.self)) } catch {
do { self = .bool(try container.decode(Bool.self)) } catch {
do { self = .double(try container.decode(Double.self)) } catch {
do { self = .array(try container.decode([JSON].self)) } catch {
self = .dictionary(try container.decode([String: JSON].self))
}
}
}
}
}
}
}

extension JSON {
func decode<T>(_ type: T.Type) throws -> T where T: Decodable {
try JSONDecoder().decode(type, from: JSONEncoder().encode(self))
}
}

With that JSON type in place, let’s implement our full DTO-based parsing.

let json = """
{
"items": [
{
"type": "text",
"id": 55,
"text": "This is a text feed item"
},
{
"type": "image",
"id": 56,
"image_url": "http://placekitten.com/200/300"
}
]
}
""".data(using: .utf8)!

class FeedItem {
let id: Int

init(id: Int) {
self.id = id
}
}

class TextFeedItem: FeedItem {
let text: String

init(text: String, id: Int) {
self.text = text
super.init(id: id)
}
}

class ImageFeedItem: FeedItem {
let url: URL

init(url: URL, id: Int) {
self.url = url
super.init(id: id)
}
}

struct Feed {
let items: [FeedItem]
}

extension Feed: DecodableFromDTO {
struct DTO: Decodable {
var items: [JSON]

struct Text: Decodable {
var id: Int
var text: String
}

struct Image: Decodable {
var id: Int
var image_url: URL
}

struct DecodingError: Error {}
}

init(from dto: DTO) throws {
items = try dto.items.map { json in
guard case .dictionary(let item) = json, case .string(let type) = item["type"] else { throw DTO.DecodingError() }
switch type {
case "text":
let textDTO = try json.decode(DTO.Text.self)
return TextFeedItem(text: textDTO.text, id: textDTO.id)

case "image":
let imageDTO = try json.decode(DTO.Image.self)
return ImageFeedItem(url: imageDTO.image_url, id: imageDTO.id)

default:
throw DTO.DecodingError()
}
}
}
}

let feed = try! JSONDecoder().decode(Feed.self, from: json)
dump(feed)

SNR:

  • Codable SNR = 95/45 ~ 47% of noise.
  • DTO SNR = 85/35 ~ 41% of noise.

Impact of changes:

  • Codable: update TextFeedItem, ImageFeedItem, and Feed’s CodingKeys and init(from decoder:) throws (and maybe FeedItem as well).
  • DTO: the JSON type adapts to any kind of JSON shape, so we only have to update Feed.DTO.Text, Feed.DTO.Image and init(from dto:) throws.

As before, it seems that the impact of changes is better scoped in the DTO version. But in this case, the main advantage to me is that the code inside init(from dto:) throws is much simpler than the code inside init(from decoder:) throws.

Mix and match

In case we need to combine DecodableFromDTO objects with Decodable objects, we can use the DecodeFromDTO property wrapper for that.

let json = """
{
"name": "Luis",
"beer": {
"id": 123,
"name": "Endeavor",
"brewery": {
"id": "sa001",
"name": "Saint arnold"
}
}
}
""".data(using: .utf8)!

@propertyWrapper
struct DecodeFromDTO<T>: Decodable where T: DecodableFromDTO {
var wrappedValue: T

init(from decoder: Decoder) throws {
wrappedValue = try T(from: T.DTO(from: decoder))
}
}

struct Person: Decodable {
var name: String
@DecodeFromDTO var beer: Beer
}

let person = try! JSONDecoder().decode(Person.self, from: json)
dump(person)

Conclusion

While neither the SNR nor the impact of changes have been objectively better or different in any of the approaches, I still think separating the schema types from the domain types is a good practice that scales better, leads to richer types, and facilitates refactoring.

Also, the declarative nature of the DTO approach, with its structure matching the JSON schema (which could be automated by tools like Quicktype), feels much nicer to me than a lot of complex imperative parsing code. But at the end of the day, this is just a tool, and we should use the right tool for the job.

--

--

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.