David Yang
Tips and posts for iOS developers from an iOS developer.
Using Codable with mixed types content
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.