How can I implement polymorphic decoding of JSON data in Swift 4?
Asked Answered
C

5

14

I am attempting to render a view from data returned from an API endpoint. My JSON looks (roughly) like this:

{
  "sections": [
    {
      "title": "Featured",
      "section_layout_type": "featured_panels",
      "section_items": [
        {
          "item_type": "foo",
          "id": 3,
          "title": "Bisbee1",
          "audio_url": "http://example.com/foo1.mp3",
          "feature_image_url" : "http://example.com/feature1.jpg"
        },
        {
          "item_type": "bar",
          "id": 4,
          "title": "Mortar8",
          "video_url": "http://example.com/video.mp4",
          "director" : "John Smith",
          "feature_image_url" : "http://example.com/feature2.jpg"
        }
      ]
    }    
  ]
}

I have an object that represents how to layout a view in my UI. It looks like this:

public struct ViewLayoutSection : Codable {
    var title: String = ""
    var sectionLayoutType: String
    var sectionItems: [ViewLayoutSectionItemable] = []
}

ViewLayoutSectionItemable is a protocol that includes, among other things, a title and a URL to an image to use in the layout.

However, the sectionItems array is actually made up of different types. What I'd like to do is instantiate each section item as an instance of its own class.

How do I setup the init(from decoder: Decoder) method for the ViewLayoutSection to let me iterate over the items in that JSON array and create an instance of the proper class in each case?

Cornhusking answered 5/10, 2017 at 21:43 Comment(1)
S
1

I recommend you to be judicious on the use of Codable. If you only want to decode a type from JSON and not encode it, conforming it to Decodable alone is enough. And since you have already discovered that you need to decode it manually (via a custom implementation of init(from decoder: Decoder)), the question becomes: what is the least painful way to do it?

First, the data model. Note that ViewLayoutSectionItemable and its adopters do not conform to Decodable:

enum ItemType: String, Decodable {
    case foo
    case bar
}

protocol ViewLayoutSectionItemable {
    var id: Int { get }
    var itemType: ItemType { get }
    var title: String { get set }
    var imageURL: URL { get set }
}

struct Foo: ViewLayoutSectionItemable {
    let id: Int
    let itemType: ItemType
    var title: String
    var imageURL: URL
    // Custom properties of Foo
    var audioURL: URL
}

struct Bar: ViewLayoutSectionItemable {
    let id: Int
    let itemType: ItemType
    var title: String
    var imageURL: URL
    // Custom properties of Bar
    var videoURL: URL
    var director: String
}

Next, here's how we will decode the JSON:

struct Sections: Decodable {
    var sections: [ViewLayoutSection]
}

struct ViewLayoutSection: Decodable {
    var title: String = ""
    var sectionLayoutType: String
    var sectionItems: [ViewLayoutSectionItemable] = []

    // This struct use snake_case to match the JSON so we don't have to provide a custom
    // CodingKeys enum. And since it's private, outside code will never see it
    private struct GenericItem: Decodable {
        let id: Int
        let item_type: ItemType
        var title: String
        var feature_image_url: URL
        // Custom properties of all possible types. Note that they are all optionals
        var audio_url: URL?
        var video_url: URL?
        var director: String?
    }

    private enum CodingKeys: String, CodingKey {
        case title
        case sectionLayoutType = "section_layout_type"
        case sectionItems = "section_items"
    }

    public init(from decoder: Decoder) throws {
        let container     = try decoder.container(keyedBy: CodingKeys.self)
        title             = try container.decode(String.self, forKey: .title)
        sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
        sectionItems      = try container.decode([GenericItem].self, forKey: .sectionItems).map { item in
        switch item.item_type {
        case .foo:
            // It's OK to force unwrap here because we already
            // know what type the item object is
            return Foo(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, audioURL: item.audio_url!)
        case .bar:
            return Bar(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, videoURL: item.video_url!, director: item.director!)
        }
    }
}

Usage:

let sections = try JSONDecoder().decode(Sections.self, from: json).sections
Subpoena answered 6/10, 2017 at 3:56 Comment(3)
One big takeaway for me from this is that it'll probably be better to re-think how I'm setting up and using my API than to jam it through in this manner.Cornhusking
Unless your types are really ONLY limited to Foo and Bar, which is very unlikely to remain true forever in my experience, this design is quickly going to get out of hand. The problem is this comment: Custom properties of all possible types... what if you need to add many new types later on? Or many different properties are possible on each type? GenericItem will quickly get huge...! This isn't good... Wherever possible, strive to shift logic to the polymorphic objects and keep the factory/intermediary objects small.Eddra
Be cautious about the statement "it's OK to force unwrap here because we already know what type the item object is". You know what type the item object should be, but if the input JSON is malformed and missing one of those keys, that bad input data will crash the app.Floatable
E
9

Polymorphic design is a good thing: many design patterns exhibit polymorphism to make the overall system more flexible and extensible.

Unfortunately, Codable doesn't have "built in" support for polymorphism, at least not yet.... there's also discussion about whether this is actually a feature or a bug.

Fortunately, you can pretty easily create polymorphic objects using an enum as an intermediate "wrapper."

First, I'd recommend declaring itemType as a static property, instead of an instance property, to make switching on it easier later. Thereby, your protocol and polymorphic types would look like this:

import Foundation

public protocol ViewLayoutSectionItemable: Decodable {
  static var itemType: String { get }

  var id: Int { get }
  var title: String { get set }
  var imageURL: URL { get set }
}

public struct Foo: ViewLayoutSectionItemable {
  
  // ViewLayoutSectionItemable Properties
  public static var itemType: String { return "foo" }
  
  public let id: Int
  public var title: String
  public var imageURL: URL
  
  // Foo Properties
  public var audioURL: URL
}

public struct Bar: ViewLayoutSectionItemable {
  
  // ViewLayoutSectionItemable Properties
  public static var itemType: String { return "bar" }
  
  public let id: Int
  public var title: String
  public var imageURL: URL
  
  // Bar Properties
  public var director: String
  public var videoURL: URL
}

Next, create an enum for the "wrapper":

public enum ItemableWrapper: Decodable {
  
  // 1. Keys
  fileprivate enum Keys: String, CodingKey {
    case itemType = "item_type"
    case sections
    case sectionItems = "section_items"
  }
  
  // 2. Cases
  case foo(Foo)
  case bar(Bar)
  
  // 3. Computed Properties
  public var item: ViewLayoutSectionItemable {
    switch self {
    case .foo(let item): return item
    case .bar(let item): return item
    }
  }
  
  // 4. Static Methods
  public static func items(from decoder: Decoder) -> [ViewLayoutSectionItemable] {
    guard let container = try? decoder.container(keyedBy: Keys.self),
      var sectionItems = try? container.nestedUnkeyedContainer(forKey: .sectionItems) else {
        return []
    }
    var items: [ViewLayoutSectionItemable] = []
    while !sectionItems.isAtEnd {
      guard let wrapper = try? sectionItems.decode(ItemableWrapper.self) else { continue }
      items.append(wrapper.item)
    }
    return items
  }
  
  // 5. Decodable
  public init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: Keys.self)
    let itemType = try container.decode(String.self, forKey: Keys.itemType)
    switch itemType {
    case Foo.itemType:  self = .foo(try Foo(from: decoder))
    case Bar.itemType:  self = .bar(try Bar(from: decoder))
    default:
      throw DecodingError.dataCorruptedError(forKey: .itemType,
                                             in: container,
                                             debugDescription: "Unhandled item type: \(itemType)")
    }
  }
}

Here's what the above does:

  1. You declare Keys that are relevant to the response's structure. In your given API, you're interested in sections and sectionItems. You also need to know which key represents the type, which you declare here as itemType.

  2. You then explicitly list every possible case: this violates the Open Closed Principle, but this is "okay" to do as it's acting as a "factory" for creating items....

    Essentially, you'll only have this ONCE throughout your entire app, just right here.

  3. You declare a computed property for item: this way, you can unwrap the underlying ViewLayoutSectionItemable without needing to care about the actual case.

  4. This is the heart of the "wrapper" factory: you declare items(from:) as a static method that's capable of returning [ViewLayoutSectionItemable], which is exactly what you want to do: pass in a Decoder and get back an array containing polymorphic types! This is the method you'll actually use instead of decoding Foo, Bar or any other polymorphic arrays of these types directly.

  5. Lastly, you must make ItemableWrapper implement the Decodable method. The trick here is that ItemWrapper always decodes an ItemWrapper: thereby, this works how Decodable is expecting.

As it's an enum, however, it's allowed to have associated types, which is exactly what you do for each case. Hence, you can indirectly create polymorphic types!

Since you've done all the heavy lifting within ItemWrapper, it's very easy to now go from a Decoder to an `[ViewLayoutSectionItemable], which you'd do simply like this:

let decoder = ... // however you created it
let items = ItemableWrapper.items(from: decoder)
Eddra answered 15/1, 2018 at 13:16 Comment(3)
I'm trying to implement your solution for a JsonAPI type response. I do have a simple question, though... how do you create your decoder?? JsonDecoder doesn't seem to have a method that returns one.Flesher
You don't create an instance of Decoder. Instead, JSONDecoder creates one internally whenever you call try jsonDecoder.decode(...). See this tutorial for help: raywenderlich.com/172145/…Eddra
Thank you for your reply. I'm starting to get the hang of it. Your answer helped me a lot and I feel it should be the accepted answer.Flesher
D
7

A simpler version of @CodeDifferent's response, which addresses @JRG-Developer's comment. There is no need to rethink your JSON API; this is a common scenario. For each new ViewLayoutSectionItem you create, you only need to add one case and one line of code to the PartiallyDecodedItem.ItemKind enum and PartiallyDecodedItem.init(from:) method respectively.

This is not only the least amount of code compared to the accepted answer, it is more performant. In @CodeDifferent's option, you are required to initialize 2 arrays with 2 different representations of the data to get your array of ViewLayoutSectionItems. In this option, you still need to initialize 2 arrays, but get to only have one representation of the data by taking advantage of copy-on-write semantics.

Also note that it is not necessary to include ItemType in the protocol or the adopting structs (it doesn't make sense to include a string describing what type a type is in a statically typed language).

protocol ViewLayoutSectionItem {
    var id: Int { get }
    var title: String { get }
    var imageURL: URL { get }
}

struct Foo: ViewLayoutSectionItem {
    let id: Int
    let title: String
    let imageURL: URL

    let audioURL: URL
}

struct Bar: ViewLayoutSectionItem {
    let id: Int
    let title: String
    let imageURL: URL

    let videoURL: URL
    let director: String
}

private struct PartiallyDecodedItem: Decodable {
    enum ItemKind: String, Decodable {
        case foo, bar
    }
    let kind: Kind
    let item: ViewLayoutSectionItem

    private enum DecodingKeys: String, CodingKey {
        case kind = "itemType"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DecodingKeys.self)
        self.kind = try container.decode(Kind.self, forKey: .kind)
        self.item = try {
            switch kind {
            case .foo: return try Foo(from: decoder)
            case .number: return try Bar(from: decoder)
        }()
    }
}

struct ViewLayoutSection: Decodable {
    let title: String
    let sectionLayoutType: String
    let sectionItems: [ViewLayoutSectionItem]

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.title = try container.decode(String.self, forKey: .title)
        self.sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
        self.sectionItems = try container.decode([PartiallyDecodedItem].self, forKey: .sectionItems)
            .map { $0.item }
    }
}

To handle the snake case -> camel case conversion, rather than manually type out all of the keys, you can simply set a property on JSONDecoder

struct Sections: Decodable {
    let sections: [ViewLayoutSection]
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let sections = try decode(Sections.self, from: json)
    .sections
Denominational answered 27/5, 2019 at 15:52 Comment(3)
I think you meant to name the enum Kind and not ItemKind.Triglyceride
This is definitely the simplest of the answers so far while making it easy to add future items. I edited the answer for better compilation and that makes Frankie's comment obsolete.Erlin
There is something wrong in PartiallyDecodedItem.init – a bracket is missing plus I get "Constant 'self.item' used before being initialized" from the compiler. What am I missing (swift 5)?Waters
S
1

I recommend you to be judicious on the use of Codable. If you only want to decode a type from JSON and not encode it, conforming it to Decodable alone is enough. And since you have already discovered that you need to decode it manually (via a custom implementation of init(from decoder: Decoder)), the question becomes: what is the least painful way to do it?

First, the data model. Note that ViewLayoutSectionItemable and its adopters do not conform to Decodable:

enum ItemType: String, Decodable {
    case foo
    case bar
}

protocol ViewLayoutSectionItemable {
    var id: Int { get }
    var itemType: ItemType { get }
    var title: String { get set }
    var imageURL: URL { get set }
}

struct Foo: ViewLayoutSectionItemable {
    let id: Int
    let itemType: ItemType
    var title: String
    var imageURL: URL
    // Custom properties of Foo
    var audioURL: URL
}

struct Bar: ViewLayoutSectionItemable {
    let id: Int
    let itemType: ItemType
    var title: String
    var imageURL: URL
    // Custom properties of Bar
    var videoURL: URL
    var director: String
}

Next, here's how we will decode the JSON:

struct Sections: Decodable {
    var sections: [ViewLayoutSection]
}

struct ViewLayoutSection: Decodable {
    var title: String = ""
    var sectionLayoutType: String
    var sectionItems: [ViewLayoutSectionItemable] = []

    // This struct use snake_case to match the JSON so we don't have to provide a custom
    // CodingKeys enum. And since it's private, outside code will never see it
    private struct GenericItem: Decodable {
        let id: Int
        let item_type: ItemType
        var title: String
        var feature_image_url: URL
        // Custom properties of all possible types. Note that they are all optionals
        var audio_url: URL?
        var video_url: URL?
        var director: String?
    }

    private enum CodingKeys: String, CodingKey {
        case title
        case sectionLayoutType = "section_layout_type"
        case sectionItems = "section_items"
    }

    public init(from decoder: Decoder) throws {
        let container     = try decoder.container(keyedBy: CodingKeys.self)
        title             = try container.decode(String.self, forKey: .title)
        sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
        sectionItems      = try container.decode([GenericItem].self, forKey: .sectionItems).map { item in
        switch item.item_type {
        case .foo:
            // It's OK to force unwrap here because we already
            // know what type the item object is
            return Foo(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, audioURL: item.audio_url!)
        case .bar:
            return Bar(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, videoURL: item.video_url!, director: item.director!)
        }
    }
}

Usage:

let sections = try JSONDecoder().decode(Sections.self, from: json).sections
Subpoena answered 6/10, 2017 at 3:56 Comment(3)
One big takeaway for me from this is that it'll probably be better to re-think how I'm setting up and using my API than to jam it through in this manner.Cornhusking
Unless your types are really ONLY limited to Foo and Bar, which is very unlikely to remain true forever in my experience, this design is quickly going to get out of hand. The problem is this comment: Custom properties of all possible types... what if you need to add many new types later on? Or many different properties are possible on each type? GenericItem will quickly get huge...! This isn't good... Wherever possible, strive to shift logic to the polymorphic objects and keep the factory/intermediary objects small.Eddra
Be cautious about the statement "it's OK to force unwrap here because we already know what type the item object is". You know what type the item object should be, but if the input JSON is malformed and missing one of those keys, that bad input data will crash the app.Floatable
O
1

I have written a blog post about this exact problem.

In summary. I suggest defining an extension on Decoder

extension Decoder {
  func decode<ExpectedType>(_ expectedType: ExpectedType.Type) throws -> ExpectedType {
    let container = try self.container(keyedBy: PolymorphicMetaContainerKeys.self)
    let typeID = try container.decode(String.self, forKey: .itemType)
     
    guard let types = self.userInfo[.polymorphicTypes] as? [Polymorphic.Type] else {
      throw PolymorphicCodableError.missingPolymorphicTypes
    }
     
    let matchingType = types.first { type in
      type.id == typeID
    }
     
    guard let matchingType = matchingType else {
      throw PolymorphicCodableError.unableToFindPolymorphicType(typeID)
    }
     
    let decoded = try matchingType.init(from: self)
     
    guard let decoded = decoded as? ExpectedType else {
      throw PolymorphicCodableError.unableToCast(
        decoded: decoded,
        into: String(describing: ExpectedType.self)
      )
    }
    return decoded
  }
} 

Then adding the possible polymorphic types to the Decoder instance:

var decoder = JSONDecoder()
decoder.userInfo[.polymorphicTypes] = [
  Snake.self,
  Dog.self
]

If you have nested polymeric values you can write a property wrapper to that calls this decode method so that you do not need to define custom init(from:).

Olympian answered 9/4, 2021 at 3:37 Comment(1)
I have implemented the blog post code yet the decoder never seems to attempt to decide the polymorphic property wrapped values. Could you show some working code and JSON file so I can trace the issue a bit better?Egidius
P
0

Here's a small utility package that resolve this exact problem.

It was built around a configuration type that has variants for the decodable type defines the type information discriminator.

enum DrinkFamily: String, ClassFamily {
    case drink = "drink"
    case beer = "beer"

    static var discriminator: Discriminator = .type
    
    typealias BaseType = Drink

    func getType() -> Drink.Type {
        switch self {
        case .beer:
            return Beer.self
        case .drink:
            return Drink.self
        }
    }
}

Later in your collection overload the init method to use our KeyedDecodingContainer extension.

class Bar: Decodable {
    let drinks: [Drink]

    private enum CodingKeys: String, CodingKey {
        case drinks
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        drinks = try container.decodeHeterogeneousArray(OfFamily: DrinkFamily.self, forKey: .drinks)
    }
}
Pry answered 18/5, 2021 at 10:40 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.