David Yang

Tips and posts for iOS developers from an iOS developer.

As mobile developers, we usually know exactly what kind of data we’re manipulating and have an idea of what we expect from API responses. But sometimes we don’t get the data structured as we would wish.

In today’s article, we’ll focus on a specific case where we get some content in the form of an array with different type of data in it.

The simple cases

Let’s say we want to decode the following:

{
    "type": "movie",
    "id": 1,
    "title": "Iron Man",
    "country": "USA"
}

We would need a Movie struct conforming to Decodable (here Codable in order to have Encodable behavior too).

struct Movie: Codable {
    let id: Int
    let title: String
    let country: String
}

Now we can decode it using a JSONDecoder instance.

let moviesData = """
{
    "type": "movie",
    "id": 1,
    "title": "Iron Man",
    "country": "USA"
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
let movies = try decoder.decode(Movie.self, from: moviesData)
print(movies)

// Output:
// Movie(id: 1, title: "Iron Man", country: "USA")

Note

You may notice that:

  • we’re not decoding the type field: you can ommit any content if you judge you don’t need it. Actually, the lesser the better for obvious performance reasons.
  • our properties have the exact same names as the data payload. If you want to map them to a new name, you should use a CodingKeys enum to achieve this.

Mixed type contents

Now what if instead of returning a single type element, the API returns a list of elements that can be of different types.

[{
    "type": "movie",
    "id": 100,
    "title": "The Avengers",
    "country": "USA"
},
{
    "type": "person",
    "id": 101,
    "name": "Tom Holland",
    "role": "Actor"
},
{
    "type": "tvshow",
    "id": 102,
    "title": "Marvel's Agents of S.H.I.E.L.D.",
    "network": "ABC"
}]

As you can see here, now we get an array containing a Movie, a Person and a TvShow.

Those items are clearly different in their structure.

What to avoid…

The first simple and naive approach may be to create a new type of structure called Content containing and id, type and some optional properties: name, title, country, role and network.

// DON'T
struct Content: Codable {
    let id: Int
    let type: String
    let name, title, country, role, network: String?
}

With that approach though, you’ll probably meet more design flaws later in your data model structure and architecture. For instance, a new type of data may be showing up someday and your app may not be prepared to handle it. Plus, Swift is beloved by developer for all its type-related features (safety, generics, inference, etc.).

Another thing I would avoid, if you’re coming from Object-Oriented Programming, would be to have a superclass Content and some Movie, Person and TvShow classes inherit from it.

// DON'T
class Content: Codable {
    let id: Int
    let type: String
}

class Movie: Content {
    let title, country: String
}
class Person: Content {
    let name, role: String
}
class TvShow: Content {
    let title, network: String
}

Though sometimes convenient, OOP also have its flaws. I will not get into those details here but you may check Krusty’s advices from one of WWDC 2016 best sessions available here.

The Swift-y way

To describe it simply, it will be about taking advantage of Swift’s enum and wrap the typed data in different cases.

enum Content {
    case movie(Movie)
    case person(Person)
    case tvShow(TvShow)
}

We already had our Movie struct defined earlier. Let’s do the same for Person and TvShow.

struct Person: Codable {
    let id: Int
    let name: String
    let role: String
}

struct TvShow: Codable {
    let id: Int
    let title: String
    let network: String
}

Now, we’ll need to have Content conform to Codable and implement the init(from decoder: Decoder) and encode(to encoder: Encoder) methods.

You’ll also notice that we will only decode one key from the JSON payload, the discriminating one, type: this is the only key we need here, it will tell us what type of data to expect and in order decode it.

Finally, we’ll end up with the following implementation.

extension Content: Codable {
    private enum CodingKeys: String, CodingKey {
        case type = "type"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let singleContainer = try decoder.singleValueContainer()
        
        let type = try container.decode(String.self, forKey: .type)
        switch type {
        case "movie":
            let movie = try singleContainer.decode(Movie.self)
            self = .movie(movie)
        case "person":
            let person = try singleContainer.decode(Person.self)
            self = .person(person)
        case "tvshow":
            let tvShow = try singleContainer.decode(TvShow.self)
            self = .tvShow(tvShow)
        default:
            fatalError("Unknown type of content.")
            // or handle this case properly
        }
    }
    
    func encode(to encoder: Encoder) throws {
        var singleContainer = encoder.singleValueContainer()
        
        switch self {
        case .movie(let movie):
            try singleContainer.encode(movie)
        case .person(let person):
            try singleContainer.encode(person)
        case .tvShow(let tvShow):
            try singleContainer.encode(tvShow)
        }
    }
}

Now let’s try to decode our data.

let decoder = JSONDecoder()
let content = try decoder.decode([Content].self, from: mixedData)
print(content)

// Output:
// [
// Content.movie(Movie(id: 100, title: "The Avengers", country: "USA")), 
// Content.person(Person(id: 101, name: "Tom Holland", role: "Actor")), 
// Content.tvShow(TvShow(id: 102, title: "Marvel\'s Agents of S.H.I.E.L.D.", network: "ABC"))
// ]

As you can see, we now get the expected data, typed and wrapped in our Content enum. We’ll be able to iterate through it and use switch or if case let syntaxes to retrieve the data.

We can also make sure that our Content struct works as expected with Encodable behavior by using a JSONEncoder.

do {
    let encoder = JSONEncoder()
    let data = try encoder.encode(content)
    print(String(data: data, encoding: .utf8))
} catch {
    print("Could not encode back mixed content")
}

// Output:
// Optional("[{\"id\":100,\"title\":\"The Avengers\",\"country\":\"USA\"},{\"id\":101,\"name\":\"Tom Holland\",\"role\":\"Actor\"},{\"id\":102,\"title\":\"Marvel\'s Agents of S.H.I.E.L.D.\",\"network\":\"ABC\"}]")

As you can see, when encoded back, the data structure matches the one we started from, with nothing more or less.

TL;DR

Swift enums are the way. I have spoken.

0C122567-9543-4EDF-9679-CA3AD52274DC