I am attempting to render a view from data returned from an API endpoint. My JSON looks (roughly) like this:
{
\"sections\": [
{
\"title\": \"Feature
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:
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
.
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.
You declare a computed property for item
: this way, you can unwrap the underlying ViewLayoutSectionItemable
without needing to care about the actual case
.
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.
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)
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 ViewLayoutSectionItem
s. 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
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