Dynamic Encodable with type erasure

Swift 4 brought us new Codable APIs aiming to simplify serializing and deserializing data models into various formats, most popular of which is json. Swift 4 compiler has special support for these APIs in form of code autogeneration. This post is about a small yet annoying problem that you might face while adopting Encodable protocol for dynamic structure models.

The problem

Suppose we want to serialize some data, which can be structured in a dynamic way. Let me explain what I mean by that. We will be serializing model Packet which contains some statically defined properties, like date, as well as one dynamically defined property called payload. The reason why I call it dynamic is that several different types should be able to fit into that payload. The only common thing between those types is that they should be Encodable as well.

struct SomeDataModel: Encodable {}
struct SomeOtherDataModel: Encodable {}
struct Packet: Encodable {
var date: Date
var payload: Encodable
}

If you try to put that into playground it won’t compile

cannot automatically synthesize ‘Encodable’ because ‘Encodable’ does not conform to ‘Encodable’

That reads odd, doesn’t it? Well, let’s try to figure out what’s wrong. Encodable requires to have only one method

public func encode(to encoder: Encoder) throws

so maybe we can implement that method ourselves? For that we will also have to define CodingKeys enum. Let’s see

struct Packet: Encodable {
var date: Date
var payload: Encodable
  enum CodingKeys: String, CodingKey {
case date, payload
}
  public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(date, forKey: .date)
try container.encode(payload, forKey: .payload)
}
}

That doesn’t compile either:

error: ambiguous reference to member ‘encode(_:forKey:)’

and it only complains about the line where we try to encode payload. And the reason why it complains is that encode for custom types is actually generic method parameterized with that custom type. It’s static, meaning that it requires to know exact type of what we’re trying to encode before invoking encode(forKey:). Since we don’t have type at this point, we only know that this type conforms to Encodable. To me — that should be enough, after all, that’s all encode(forKey:) cares about. But it’s just the way encodable APIs were designed.

There are ways to make Packet conform to Encodable with dynamic payload. Most straightforward (and I would argue — the intended) way would be to make Packet generic:

struct Packet<T: Encodable>: Encodable {
var date: Date
var payload: T
}

and it works! Even without explicit Encodable implementation — compiler can autogenerate all that boilerplate for us.

There is downside to this approach though. Since Packet is now generic — you’d have to make every method taking Packet as a parameter to be generic over payload type. That may require a lot of changes in existing code, not to mention, also leaks of implementation details. And if you happen to have method returning Packet — you’re going to have even more headache.

Good old Type Erasure

There is a way to simplify Packet using the Type Erasure pattern. Consider we have helping internal type

struct AnyEncodable: Encodable {
var _encodeFunc: (Encoder) throws -> Void

init(_ encodable: Encodable) {
func _encode(to encoder: Encoder) throws {
try encodable.encode(to: encoder)
}
self._encodeFunc = _encode
}
  func encode(to encoder: Encoder) throws {
try _encodeFunc(encoder)
}
}

As you can see, this is a classic example of type erasure. It can turn any dynamic type conforming to Encodable into statically known type (since AnyEncodable is a struct) that also conforms to Encodable. We can use AnyEncodable as substitute for Encodable:

struct Packet: Encodable {
var date: Date
var payload: AnyEncodable
}

Compiler is perfectly fine with such definition: AnyEncodable is a statically known type (it’s a struct after all) and can happily autogenerate all the code required for Encodable for us. With small handy addition to our Packet type:

init(date: Date, payload: Encodable) {
self.date = date
self.payload = AnyEncodable(payload)
}

we can use Packet in the same way we wanted to use it from the beginning when trying to annotate payload as Encodable.

As a conclusion I wanted to emphasize again how type erasure isn’t the only way to approach the problem but how it allows us to have minimum integration changes and keep APIs simple.