JSON Wars: Codable ⚔️ Unbox
Codable got everyone excited because we all love parsing JSON and it’s nice to have this tool as a part of the standard library. Now it’s time to test if it’s worthy enough to be our favorite one.Let’s see how Codable performs under rough conditions. We’re going to parse the following JSON using Codable and then compare it against the popular Unbox/Wrap implementation.
{
"placeName": "İstanbul, Turkey",
"lat": 41.0049823,
"lon": 28.7319958,
"dateAdded": "2018-05-25",
"info": "İstanbul is a very historical city."
}
We want our data model to look like the following.
struct Place: Codable {
let name: String
let coordinate: Coordinate
let dateAdded: Date
let info: String?
}
struct Coordinate {
let latitude: Decimal
let longitude: Decimal
}
Challenges
placeName
andname
doesn’t match. We need to tweak coding keys.- Two separate keys
lat
andlon
become a Coordinate object. Custom decoding is needed. - We need to use
YYYY-mm-dd
date format fordateAdded
field. - We need to throw an error if
name
,coordinate
anddateAdded
fields are missing butinfo
field is optional.
Implementation Using Codable
extension Place {
enum CodingKeys: String, CodingKey {
case name = "placeName"
case lat
case lon
case dateAdded
case info
}
static let dateAddedFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-mm-dd"
return formatter
}()
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(Place.dateAddedFormatter)
return decoder
}()
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(Place.dateAddedFormatter)
return encoder
}()
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
let lat = try container.decode(Decimal.self, forKey: .lat)
let lon = try container.decode(Decimal.self, forKey: .lon)
coordinate = Coordinate(latitude: lat, longitude: lon)
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
info = try container.decodeIfPresent(String.self, forKey: .info)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(coordinate.latitude, forKey: .lat)
try container.encode(coordinate.longitude, forKey: .lon)
try container.encode(name, forKey: .name)
try container.encode(dateAdded, forKey: .dateAdded)
try container.encodeIfPresent(info, forKey: .info)
}
}
Implementation Using Unbox & Wrap
extension Place: Unboxable {
static let dateAddedFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY-mm-dd"
return formatter
}()
init(unboxer: Unboxer) throws {
name = try unboxer.unbox(key: "placeName")
let lat: Decimal = try unboxer.unbox(key: "lat")
let lon: Decimal = try unboxer.unbox(key: "lon")
coordinate = Coordinate(latitude: lat, longitude: lon)
dateAdded = try unboxer.unbox(key: "dateAdded", formatter: Place.dateAddedFormatter)
info = unboxer.unbox(key: "info")
}
}
extension Place: WrapCustomizable {
func keyForWrapping(propertyNamed propertyName: String) -> String? {
if propertyName == "name" {
return "placeName"
} else {
return propertyName
}
}
func wrap(context: Any?, dateFormatter: DateFormatter?) -> Any? {
let dateFormatter = dateFormatter ?? Place.dateAddedFormatter
guard var payload = try? Wrapper(context: context, dateFormatter: dateFormatter).wrap(object: self) else {
return nil
}
payload["coordinate"] = nil
payload["lat"] = coordinate.latitude
payload["lon"] = coordinate.latitude
return payload
}
}
Usage
Their usage is almost the same. That is, of course, if we define decoder and encoder as a class property.
Codable:
let place = try Place.decoder.decode(Place.self, from: jsonData)
let placeData = try Place.encoder.encode(place)
Unbox/Wrap:
let place = try Place(unboxer: Unboxer(data: jsonData))
let placeData = try wrap(place) as Data
Comparison
- Codable implementation is 47 lines, whereas Unbox implementation is 39 lines for this example.
- Keys in Codable is always strongly typed (CodingKeys) while Unbox only accepts String keys.
- While decoding properties, Unbox infers the type but Codable requires the type as a parameter. However, this can be enabled in Codable as well using a simple extension like KeyedDecodingContainer+TypeInference.
// Codable:
try container.decode(String.self, forKey: .name)
// Unbox:
name = try unboxer.unbox(key: "placeName")
- Codable only supports one date encoding/decoding strategy. So if we had a UNIX timestamp property along with
dateAdded
, we would need to manually transform it into aDate
object ininit(from decoder:)
method. - Codable has lots of advanced features around keys, which are not available in Unbox/Wrap. (See: Using JSON with Custom Types)
- Unbox/Wrap is a third-party dependency while Codable is a part of standard library.
Bottom Line
While Codable requires some research and digestion before advanced usage, you can hit the ground running with Unbox in minutes. For serialization, Unbox only requires you to implement an init
method which defines all the mappings.
On the other hand, Codable is black magic.
Most of the cases, it just works without any customization. Although advanced usage can sometimes be complicated due to keys being strongly typed, it’s easy to learn. Codable is like a Swiss army knife so I can’t think of any case you can’t handle with it.
I started this post thinking that Unbox is much simpler and readable compared to Codable for advanced usage, but sample code indicates otherwise. It might be time to move away from all third-party JSON parsing frameworks and make peace with Codable.
Well played, Apple.
Thanks for reading! Please let me know what you think and help spread the word.❤️
Image Credit: Photo by Julian Myles on Unsplash