Decoding Heterogenous JSON with Swift and Decodable

Yet another blog precipitated by a Stack Overflow post that niggled away at me. https://stackoverflow.com/questions/58977019/parsing-nested-unkeyed-json-with-codable/58982235#58982235. I knew it was something that I’d done in the past, but as always seems to be the case with complex JSON decoding, it took me a while to get my thinking clear and come up with a solution.

The sample data supplied in the post didn’t illustrate the problem well, so I adapted it to make it a proper heterogenous structure:

let json = """
{
  "contents": {
    "data": [
      {
        "type": "type1",
        "id": "6a406cdd7a9cace5"
      },
      {
        "type": "type2",
        "dbl": 1.01
      },
      {
        "type": "type3",
        "int": 5
      }
    ]
  }
}

It tool me a while to recall that the easiest (if not maybe the ‘purist’) way to decode heterogenous data in a type-safe way (as Codable requires) is using an enum as an interim type that wraps the possible final types. The final types themselves all need to conform to Codable (or for this example just to Decodable as I’ve not gone as far as Encodable).

So the first step was to define the final types. For the trivial example data this wasn’t too challenging:

struct Item1: Codable {
   let type: String
   let id: String
}
struct Item2: Codable {
   let type: String
   let dbl: Double
}
struct Item3: Codable {
   let type: String
   let int: Int
}

This allowed the wrapper enum to be defined (and yes, I would probably pick a more informative name for a real implementation 🙂)

enum Interim {
  case type1 (Item1)
  case type2 (Item2)
  case type3 (Item3)
  case unknown  //to handle unexpected json structures
}

So far, so good, but then it gets slightly more complicated when it comes to instantiating the Interim from the JSON. It will need a CodingKey enum which represents all the possible keys for all the Item# types, along with a custom init(decoder:) to decode the JSON by linking the coding keys to their respective types and data:

extension Interim: Decodable {
   private enum InterimKeys: String, CodingKey {
      case type
      case id
      case dbl
      case int
   }

   init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: InterimKeys.self)
      let type = try container.decode(String.self, forKey: .type)
      switch type {
      case "type1":
         let id = try container.decode(String.self, forKey: .id)
         let item = Item1(type: type, id: id)
         self = .type1(item)
      case "type2":
         let dbl = try container.decode(Double.self, forKey: .dbl)
         let item = Item2(type: type, dbl: dbl)
         self = .type2(item)
      case "type3":
         let int = try container.decode(Int.self, forKey: .int)
         let item = Item3(type: type, int: int)
         self = .type3(item)
      default: self = .unknown
      }
   }
}

This provides the mechanism for decoding the heterogenous components, now we just need to deal with the higher-level keys. As we have a Decodable Interim type this is straightforward:

struct DataArray: Decodable {
   var data: [Interim]
}

struct Contents: Decodable {
   var contents: DataArray
}

This now means the json can be decoded like this…

let data = Data(json.utf8)
let decoder = JSONDecoder()
do {
    let contents = try decoder.decode(Contents.self, from: data)
    print(contents)
} catch {
    print("Failed to decode JSON")
    print(error.localizedDescription)
}

This successfully decodes the data into a nested structure where the major component is the array of Interim types with their associated Item# objects. The above produces the following output, showing these nested types:

Contents(contents: testbed.DataArray(data: [testbed.Interim.type1(testbed.Item1(type: "type1", id: "6a406cdd7a9cace5")), testbed.Interim.type2(testbed.Item2(type: "type2", dbl: 1.01)), testbed.Interim.type3(testbed.Item3(type: "type3", int: 5))]))

I think there should be an even better way to do this with Type Erasure to provide a more extensible solution, but I’ve not yet got my head fully around that. Once I do I’ll create a v2.0 of this post.

Leave a Reply