JSON Wars: Codable ⚔️ Unbox

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 and name doesn’t match. We need to tweak coding keys.
  • Two separate keys lat and lon become a Coordinate object. Custom decoding is needed.
  • We need to use YYYY-mm-dd date format for dateAdded field.
  • We need to throw an error if name, coordinate and dateAdded fields are missing but info 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 a Date object in init(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