Encode/Decode Array of Types conforming to protocol with JSONEncoder

The reason why your first example doesn’t compile (and your second crashes) is because protocols don’t conform to themselvesTag is not a type that conforms to Codable, therefore neither is [Tag]. Therefore Article doesn’t get an auto-generated Codable conformance, as not all of its properties conform to Codable.

Encoding and decoding only the properties listed in the protocol

If you just want to encode and decode the properties listed in the protocol, one solution would be to simply use an AnyTag type-eraser that just holds those properties, and can then provide the Codable conformance.

You can then have Article hold an array of this type-erased wrapper, rather than of Tag:

struct AnyTag : Tag, Codable {

    let type: String
    let value: String

    init(_ base: Tag) {
        self.type = base.type
        self.value = base.value
    }
}

struct Article: Codable {
    let tags: [AnyTag]
    let title: String
}

let tags: [Tag] = [
    AuthorTag(value: "Author Tag Value"),
    GenreTag(value:"Genre Tag Value")
]

let article = Article(tags: tags.map(AnyTag.init), title: "Article Title")

let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .prettyPrinted

let jsonData = try jsonEncoder.encode(article)

if let jsonString = String(data: jsonData, encoding: .utf8) {
    print(jsonString)
}

Which outputs the following JSON string:

{
  "title" : "Article Title",
  "tags" : [
    {
      "type" : "author",
      "value" : "Author Tag Value"
    },
    {
      "type" : "genre",
      "value" : "Genre Tag Value"
    }
  ]
}

and can be decoded like so:

let decoded = try JSONDecoder().decode(Article.self, from: jsonData)

print(decoded)

// Article(tags: [
//                 AnyTag(type: "author", value: "Author Tag Value"),
//                 AnyTag(type: "genre", value: "Genre Tag Value")
//               ], title: "Article Title")

Encoding and decoding all properties of the conforming type

If however you need to encode and decoded every property of the given Tag conforming type, you’ll likely want to store the type information in the JSON somehow.

I would use an enum in order to do this:

enum TagType : String, Codable {

    // be careful not to rename these – the encoding/decoding relies on the string
    // values of the cases. If you want the decoding to be reliant on case
    // position rather than name, then you can change to enum TagType : Int.
    // (the advantage of the String rawValue is that the JSON is more readable)
    case author, genre

    var metatype: Tag.Type {
        switch self {
        case .author:
            return AuthorTag.self
        case .genre:
            return GenreTag.self
        }
    }
}

Which is better than just using plain strings to represent the types, as the compiler can check that we’ve provided a metatype for each case.

Then you just have to change the Tag protocol such that it requires conforming types to implement a static property that describes their type:

protocol Tag : Codable {
    static var type: TagType { get }
    var value: String { get }
}

struct AuthorTag : Tag {

    static var type = TagType.author
    let value: String

    var foo: Float
}

struct GenreTag : Tag {

    static var type = TagType.genre
    let value: String

    var baz: String
}

Then we need to adapt the implementation of the type-erased wrapper in order to encode and decode the TagType along with the base Tag:

struct AnyTag : Codable {

    var base: Tag

    init(_ base: Tag) {
        self.base = base
    }

    private enum CodingKeys : CodingKey {
        case type, base
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        let type = try container.decode(TagType.self, forKey: .type)
        self.base = try type.metatype.init(from: container.superDecoder(forKey: .base))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(type(of: base).type, forKey: .type)
        try base.encode(to: container.superEncoder(forKey: .base))
    }
}

We’re using a super encoder/decoder in order to ensure that the property keys for the given conforming type don’t conflict with the key used to encode the type. For example, the encoded JSON will look like this:

{
  "type" : "author",
  "base" : {
    "value" : "Author Tag Value",
    "foo" : 56.7
  }
}

If however you know there won’t be a conflict, and want the properties to be encoded/decoded at the same level as the “type” key, such that the JSON looks like this:

{
  "type" : "author",
  "value" : "Author Tag Value",
  "foo" : 56.7
}

You can pass decoder instead of container.superDecoder(forKey: .base) & encoder instead of container.superEncoder(forKey: .base) in the above code.

As an optional step, we could then customise the Codable implementation of Article such that rather than relying on an auto-generated conformance with the tags property being of type [AnyTag], we can provide our own implementation that boxes up a [Tag] into an [AnyTag] before encoding, and then unbox for decoding:

struct Article {

    let tags: [Tag]
    let title: String

    init(tags: [Tag], title: String) {
        self.tags = tags
        self.title = title
    }
}

extension Article : Codable {

    private enum CodingKeys : CodingKey {
        case tags, title
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.tags = try container.decode([AnyTag].self, forKey: .tags).map { $0.base }
        self.title = try container.decode(String.self, forKey: .title)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(tags.map(AnyTag.init), forKey: .tags)
        try container.encode(title, forKey: .title)
    }
}

This then allows us to have the tags property be of type [Tag], rather than [AnyTag].

Now we can encode and decode any Tag conforming type that’s listed in our TagType enum:

let tags: [Tag] = [
    AuthorTag(value: "Author Tag Value", foo: 56.7),
    GenreTag(value:"Genre Tag Value", baz: "hello world")
]

let article = Article(tags: tags, title: "Article Title")

let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .prettyPrinted

let jsonData = try jsonEncoder.encode(article)

if let jsonString = String(data: jsonData, encoding: .utf8) {
    print(jsonString)
}

Which outputs the JSON string:

{
  "title" : "Article Title",
  "tags" : [
    {
      "type" : "author",
      "base" : {
        "value" : "Author Tag Value",
        "foo" : 56.7
      }
    },
    {
      "type" : "genre",
      "base" : {
        "value" : "Genre Tag Value",
        "baz" : "hello world"
      }
    }
  ]
}

and can then be decoded like so:

let decoded = try JSONDecoder().decode(Article.self, from: jsonData)

print(decoded)

// Article(tags: [
//                 AuthorTag(value: "Author Tag Value", foo: 56.7000008),
//                 GenreTag(value: "Genre Tag Value", baz: "hello world")
//               ],
//         title: "Article Title")

Leave a Comment