Search code examples
jsonswiftdecodableheterogeneous

Decode heterogeneous array JSON using Swift decodable


This is the JSON I am trying to decode. The value of objectType decides what object to create.

{
  "options": [
    {
      "objectType": "OptionTypeA",
      "label": "optionALabel1",
      "value": "optionAValue1"
    },
    {
      "objectType": "OptionTypeB",
      "label": "optionBLabel",
      "value": "optionBValue"
    },
    {
      "objectType": "OptionTypeA",
      "label": "optionALabel2",
      "value": "optionAValue2"
    }
  ]
}

Say I have the 2 Option Types defined like so

public protocol OptionType {
  var label: String { get }
  var value: String { get }
}

struct OptionTypeA: Decodable {
  let label: String
  let value: String
  
  // and some others...
}

struct OptionTypeB: Decodable {
  let label: String
  let value: String
  
  // and some others...
}

struct Option: Decodable {
  let options: [OptionType]
  
  enum CodingKeys: String, CodingKey {
    case options
    case label
    case value
    case objectType
  }
  
  init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)
    var optionsContainer = try values.nestedUnkeyedContainer(forKey: .options)
    var options = [OptionType]()
    
    while !optionsContainer.isAtEnd {
      let itemContainer = try optionsContainer.nestedContainer(keyedBy: CodingKeys.self)
      
      switch try itemContainer.decode(String.self, forKey: .objectType) {
      // What should I do here so that I do not have to manually decode `OptionTypeA` and `OptionTypeB`?
      case "OptionTypeA": options.append() 
      case "OptionTypeB": options.append()
      default: fatalError("Unknown type")
      }
    }
    
    self.options = options
  }
}

I know I can then manually decode each key in itemContainer and create the individual option type objects in the switch case. But I do not want to do that. How can I just decode these objects?


Solution

  • A swiftier way than a protocol for the common properties is an enum with associated values.

    The Option enum decodes first the objectType – which can even be decoded as an enum – and depending on the value it decodes the different structs.

    enum OptionType : String, Decodable {
        case a = "OptionTypeA", b = "OptionTypeB"
    }
    
    struct Root : Decodable {
        let options : [Option]
    }
    
    enum Option : Decodable {
        private enum CodingKeys : String, CodingKey { case objectType }
        
        case typeA(OptionTypeA)
        case typeB(OptionTypeB)
        
        init(from decoder : Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let typeContainer = try decoder.singleValueContainer()
            let optionType = try container.decode(OptionType.self, forKey: .objectType)
            switch optionType {
                case .a: self = .typeA(try typeContainer.decode(OptionTypeA.self))
                case .b: self = .typeB(try typeContainer.decode(OptionTypeB.self))
            }
        }
    }
    
    struct OptionTypeA: Decodable {
      let label: String
      let value: String
      
      // and some others...
    }
    
    struct OptionTypeB: Decodable {
      let label: String
      let value: String
      
      // and some others...
    }
    

    let jsonString = """
    {
      "options": [
        {
          "objectType": "OptionTypeA",
          "label": "optionALabel1",
          "value": "optionAValue1"
        },
        {
          "objectType": "OptionTypeB",
          "label": "optionBLabel",
          "value": "optionBValue"
        },
        {
          "objectType": "OptionTypeA",
          "label": "optionALabel2",
          "value": "optionAValue2"
        }
      ]
    }
    """
    
    let data = Data(jsonString.utf8)
    
    do {
        let result = try JSONDecoder().decode(Root.self, from: data)
        for option in result.options {
            switch option {
                case .typeA(let optionTypeA): print(optionTypeA)
                case .typeB(let optionTypeB): print(optionTypeB)
            }
        }
    } catch {
        print(error)
    }