I’ve been working on a recent project where I’ve been interacting with remote data. This data is delivered in JSON format. JSON (or its Swift equivalent Dictionary<String, Any>) are not exactly type safe. Seeing as type safety is a key feature of Swift, it make sense to translate this data into classes or structures. This post discusses how to do this.
The Data
For our example lets suppose we have an API that provides a list of albums and songs by Weird Al Yankovich. For convenience, instead of setting up an actual API endpoint to call I’ve taken a small sample and translated it into a JSON file. Download this and add it into your project. This is what the raw JSON looks like:
[
{
"release_date":487915200,
"title":"Dare to Be Stupid",
"tracks":[
{
"length":212,
"parody_of":"\"Like a Virgin\" by Madonna",
"sequence":1,
"title":"\"Like a Surgeon\"",
"writers":"William Steinberg, Thomas Kelly, Alfred Yankovic"
},
{
"length":205,
"parody_of":"Style parody of Devo",
"sequence":2,
"title":"\"Dare to Be Stupid\"",
"writers":"Yankovic"
},
{
"length":184,
"parody_of":"\"I Want a New Drug\" by Huey Lewis and the News",
"sequence":3,
"title":"\"I Want a New Duck\"",
"writers":"Christopher Hayes, Hugh Cregg III, Yankovic"
},
{
"length":244,
"parody_of":"Style parody of Elvis Presley-like Doo-wop",
"sequence":4,
"title":"\"One More Minute\"",
"writers":"Yankovic"
},
{
"length":238,
"parody_of":"\"Lola\" by The Kinks",
"sequence":5,
"title":"\"Yoda\"",
"writers":"Raymond Davies, Yankovic"
},
{
"length":65,
"parody_of":"Cover of titular television theme",
"sequence":6,
"title":"\"George of the Jungle\"",
"writers":"Stan Worth, Sheldon Allman"
},
{
"length":263,
"parody_of":"Style parody of 1950s sci-fi soundtracks",
"sequence":7,
"title":"\"Slime Creatures from Outer Space\"",
"writers":"Yankovic"
},
{
"length":288,
"parody_of":"\"Girls Just Want to Have Fun\" by Cyndi Lauper",
"sequence":8,
"title":"\"Girls Just Want to Have Lunch\"",
"writers":"Robert Hazard, Yankovic"
},
{
"length":186,
"parody_of":"Style parody of 1920s and 1930s music",
"sequence":9,
"title":"\"This Is the Life\"",
"writers":"Yankovic"
},
{
"length":198,
"parody_of":"Original",
"sequence":10,
"title":"\"Cable TV\"",
"writers":"Yankovic"
},
{
"length":233,
"parody_of":"A polka medley including:",
"sequence":11,
"title":"\"Hooked on Polkas\"",
"writers":""
}
]
}
]
If you are skilled at reading JSON, you should see that this is an array of albums. Each album is represented by a dictionary with keys release_date
, title
, and tracks
. These keys have types of date, string and array of dictionaries respectively. Watch track dictionary contains keys length
, parody_of
, sequence
, title
, and writers
.
We access the JSON file’s information like so:
let path = Bundle.main.path(forResource: "WierdAlAlbums", ofType: "json")!
let data = NSData.init(contentsOfFile: path)! as Data
let albums = try! JSONSerialization.jsonObject(with: data, options: []) as! [[String:Any]]
albums
is an array of dictionaries. If we want to get an album, and read its title we would need to do some unwrapping and casting:
let firstAlbumName = ((albums.first!)["title"] as! String
This should make any Swift developer cringe (and perhaps vomit in their mouth. ??) We are accessing the dictionary data with hardcoded strings (easy to mistype, hard to remember, etc) then force unwrapping the value. Barf! Additionally, each time we need to access the title, we need to do it all over again. That doesn’t seem very DRY. All in all, this is sub optimal way to handle this information. Lets see what we can do to clean this up.
The first and most important step to cleaning up and simplifying this mess is to create our own types that represent the data. This approach provides several benefits, with type safety and DRY code at the top of the list. Lets get started. We can represent our albums and track data with the following structs:
struct Album {
// 1
enum DictionaryKey:String{
case title, release_date, tracks
}
// 2
let title:String
let release_date:Date
let tracks:[Track]
// 3
init(from dict:[String:Any]){
title = dict[DictionaryKey.title.rawValue] as! String
// 3.1
let timeInterval = dict[DictionaryKey.release_date.rawValue] as! TimeInterval
release_date = Date.init(timeIntervalSince1970: timeInterval)
// 3.2
let trackData = dict[DictionaryKey.tracks.rawValue] as! [[String:Any]]
tracks = trackData.map({ (data) -> Track in
return Track(from: data)
})
}
}
struct Track{
enum DictionaryKey:String{
case length, parody_of, sequence, title, writers
}
let length:Int
let parody_of:String
let sequence:Int
let title:String
let writers:String
init(from dict:[String:Any]){
length = dict[DictionaryKey.length.rawValue] as! Int
parody_of = dict[DictionaryKey.parody_of.rawValue] as! String
title = dict[DictionaryKey.title.rawValue] as! String
writers = dict[DictionaryKey.writers.rawValue] as! String
sequence = dict[DictionaryKey.sequence.rawValue] as! Int
}
}
Lets go over what is happening here. First we have declared two structs, Album
and Track
. Their internal structure is very similar, so we will just review the key points of Album:
- We have an enum that has a case for each key in the dictionary for the data that represents the Album. Since we have declared the enum to be of type string, we can use its raw value to access the value in the dictionary. We do this to keep our code DRY (anytime we need to access a key of a dictionary representing a dictionary we use these keys). Secondly, we avoid misspelling by using an enum case.
- We have struct variables that represent a value for each key in the album dictionary.
- We have created an initializer that takes a dictionary and use the previous two steps to map the data from the dictionary to the correct struct variable.
- Since JSON doesn’t store date information directly, we translate from the time interval to the date we need. In some cases this may be a string translation, and a
DateFormatter
will be needed.
- Since the track data is an array of dictionaries we need to map the dictionaries to Tracks. We can do this either in a for loop, or via the map function (as shown above).
With these steps we can quickly and efficiently map or data from dictionaries to value types that provide type safety when accessing its information. No more forced unwrapping every time we want to use an album’s title, or a tracks sequence. While this solution is great it can be improved!
While the above code is great, we need to translate our array of dictionaries created from the JSONSerialization
into these structs:
let data = NSData.init(contentsOfFile: path)! as Data
let albumsData = try! JSONSerialization.jsonObject(with: data, options: []) as! [[String:Any]]
let albums = albumsData.map{return Album(from:$0)} // albums is of type [Album]
Now that our data has been translated into these structs, we are able to access information safely and efficiently.
While the above code is good, I can’t help but wonder if there is a better way. We take data (Data
type), transform it into another kind of data ([[String:Any]]
) then translate that into yet another type of data ([Album]
). Is there anyway we can skip the middle step and go from Data
to [Album]
?
Codable
Da-da-da-da! The Codable
protocol is perfect for this situation. If something conforms to the Codable protocol, then it is able to convert itself into and out of an external representation. I our case, we are converting from a JSON data object to our structs. (Its good to note that Codable
is a type alias that conforms to both the Decodable
and Encodable
protocols.) To explore our use of the Codable protocol, let us examine how it changes our struct representation:
struct Album:Codable{
let title:String
let release_date:Date
let tracks:[Track]
}
struct Track:Codable{
let length:Int
let parody_of:String
let sequence:Int
let title:String
let writers:String
}
Hmmmm… That simplified things quite a bit. All we are left with are the struct variables. ‘How do we use this?’ you may ask. I’ll show you:
let albums = try! decoder.decode([Album].self, from: data )// albums is of type [Album]
Boosh! Thats sweet! I love when things simplify. Its good to note that this works so well because our struct variable names are the same as the JSON keys. Hence the awkward release_date
and parody_of
variable names. They don’t exactly match the lower camel case structure that swift uses. Luckily for us, there is a way to fix this. First lets see how our struct code changes:
struct Album:Codable{
let title:String
let releaseDate:Date
let tracks:[Track]
// 1
enum CodingKeys: String, CodingKey{
// 2
case title, tracks
// 3
case releaseDate = "release_date"
}
}
struct Track:Codable{
let length:Int
let parodyOf:String
let sequence:Int
let title:String
let writers:String
enum CodingKeys: String, CodingKey{
case parodyOf = "parody_of"
case length, sequence, title, writers
}
}
The only difference between the code above, and our earlier definition is the addition of the CodingKeys
enum. Lets go over each part of this enum:
- The enum is titled CodingKeys, has a String raw type and conforms to the CodingKey protocol. This protocol indicates that these enum cases may be use for encoding and decoding. Each case in our enum represents a variable name in the struct.
- We must list any of the struct’s variable names we want to encode/decode. So we first list the ones that match our JSON keys, just to get them out of the way.
- Since our struct variable name
releaseDate
is different from the JSON key representing that data, we must map the raw value to the enum case, thus releaseDate.rawValue = "release_date"
.
Running our code works great! We can now map our JSON information to our structs while also adhering to Swift API design guidelines. Yay! Except that I’m lying. While the code runs, and mostly works, we still have a problem. Remember how we needed to translate the date data using the timeIntervalSince
initializer (3.1 above)? Yeah, that still needs to happen, but its not. While the above code compiles, runs, and returns an array of albums, a closer inspection of the return shows that something is not quite right:
print(albums.first!.releaseDate)// prints 2016-06-18 04:00:00 +0000
Yup, I’m fairly sure Dare to Be Stupid came out in 1985, not 2016… We have several solutions we could use to solve this. First, we could store the timeInterval
data as a private variable and provide releaseDate
as a computed variable:
struct Album:Codable{
let title:String
var releaseDate:Date{
return Date(timeIntervalSince1970: releaseDateTimeInterval)
}
let tracks:[Track]
private let releaseDateTimeInterval:TimeInterval
enum CodingKeys: String, CodingKey{
case releaseDateTimeInterval = "release_date"
case title, tracks
}
}
This works. However, I’m not sure I like the storage of data I don’t really need or want. Our other option is to write our own custom decoder init function:
struct Album:Codable{
let title:String
var releaseDate:Date
let tracks:[Track]
enum CodingKeys: String, CodingKey{
case releaseDate = "release_date"
case title, tracks
}
// 1
init(from decoder: Decoder) throws {
// 2
let values = try decoder.container(keyedBy: CodingKeys.self)
// 3
title = try values.decode(String.self, forKey: .title)
// 4
let timeInterval = try values.decode(Double.self, forKey: .releaseDate)
releaseDate = Date.init(timeIntervalSince1970: timeInterval)
// 5
tracks = try values.decode([Track].self, forKey: .tracks)
}
}
Lets go over this initializer:
- The first thing to note is that this is overriding the default initializer provided by conforming to the decodable protocol.
- The first thing we need to do, its to start accessing the information inside the decoder. We do this by calling the
container
method. This returns a keyed decoding container, that uses the keys we passed in (CodingKeys
)
- We use the values decoding container to access the information we need. In this case we are getting the title information. We do so by calling the
decode
method and passing in the type we are expecting and the key we are trying to access. In this case we are expecting a String
, for the key .title
.
- This step is why we are writing this custom initializer. We recognize that the key
releaseDate
doesn’t contain the date that we need, but rather the time interval needed to translate to the date we need. So we decode the data into a double, then in the next line convert that into the date we need and initialize the releaseDate
variable.
- Finally, it is worth noting that the
decode
method can decode into any type that conforms to the Decodable
protocol, also that the built in types of Array
, Dictionary
, and Optional
all conform to Codable
(and hence Decodable
) if they contain codable types. So, in this case since Track
is codable, then [Track]
is also codable, and we can use the decode method to decode the data for the tracks key into our tracks array!
If you print the album title again, it yields:
print(albums.first!.releaseDate)// prints 1985-06-18 04:00:00 +0000
Which is, of course, what we were all expecting. While we have only chosen to override the init function, if you need to subsequently encode this data (perhaps a user is allowed to alter it and send it back up) then you would also need to implement your own custom encode method.
TL;DR
Conform your classes and structs to the Codable
protocol for easy translation of JSON (or any other codable type) to the type of object that you are using!
You can customize your implementation to better conform to proper Swift API design and translate data into different form upon encoding and decoding.
Sources
https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types
https://developer.apple.com/documentation/swift/codable
https://developer.apple.com/documentation/swift/encodable
https://developer.apple.com/documentation/swift/decodable
https://www.objc.io/blog/2018/02/06/networking-with-codable/
https://developer.apple.com/documentation/foundation/archives_and_serialization/using_json_with_custom_types