Indeterminate Types with Codable in Swift
A companion playground for this post is available here.
Time flies. Swift 4.0 was released back in September 2017, and we have been enjoying the Codable
protocol for a while. And yet, we still have some ground to cover.
Recall that the Codable
protocol makes it super-easy to encode and decode values conforming to it. Oh, and it comes with Property List and JSON support. Remember the countless JSON decoding libraries before Swift 4?
In most cases, when you declare a type that adopts Codable
the compiler does most of the work and synthesizes conformance. You may also need to specify a CodingKeys
enumeration if the JSON keys do not match the property names.
However, there are some scenarios in which it is necessary to implement Codable
manually. Having a JSON with objects whose type is determined by the value of a "type"
key is one of those scenarios.
Consider a hypothetical Messaging API with support for different kinds of attachments: image, audio, etc.
{
"from": "Guille",
"text": "Look what I just found!",
"attachments": [
{
"type": "image",
"payload": {
"url": "http://via.placeholder.com/640x480",
"width": 640,
"height": 480
}
},
{
"type": "audio",
"payload": {
"title": "Never Gonna Give You Up",
"url": "https://audio.com/NeverGonnaGiveYouUp.mp3",
"shouldAutoplay": true
}
}
]
}
Because Swift is a strongly typed language, we must implement a type for each kind of attachment.
struct ImageAttachment: Codable {
let url: URL
let width: Int
let height: Int
}
struct AudioAttachment: Codable {
let title: String
let url: URL
let shouldAutoplay: Bool
}
As you can see is quite simple, until you have to implement the Message
type.
struct Message: Codable {
let from: String
let text: String
let attachments: [???]
}
To complete the implementation of Message
, we must first create an Attachment
type that can hold ImageAttachment
or AudioAttachment
values.
There are several ways of doing this. We are going to explore two different approaches, each with its pros and cons.
Using an Enum with Associated Values
Swift has a language feature that is perfect for this situation. Enumerations can store associated values of any given type.
enum Attachment {
case image(ImageAttachment)
case audio(AudioAttachment)
case unsupported
}
Notice that we are adding unsupported
in case our Messaging API decides to support new attachment types that we don’t know how to handle.
The compiler won’t be able to synthesize conformance to Codable
in this case, but it is not difficult to do it ourselves.
For the Decodable
part of Codable
, we must create a CodingKeys
enumeration and implement init(from: Decoder)
.
extension Attachment: Codable {
private enum CodingKeys: String, CodingKey {
case type
case payload
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "image":
let payload = try container.decode(ImageAttachment.self, forKey: .payload)
self = .image(payload)
case "audio":
let payload = try container.decode(AudioAttachment.self, forKey: .payload)
self = .audio(payload)
default:
self = .unsupported
}
}
...
}
For the Encodable
part of Codable
, we have to implement encode(to: Encoder)
by switching on self
and letting the associated value encode itself.
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .image(let attachment):
try container.encode("image", forKey: .type)
try container.encode(attachment, forKey: .payload)
case .audio(let attachment):
try container.encode("audio", forKey: .type)
try container.encode(attachment, forKey: .payload)
case .unsupported:
let context = EncodingError.Context(codingPath: [], debugDescription: "Invalid attachment.")
throw EncodingError.invalidValue(self, context)
}
}
Finally, we need to specify the attachments
type in Message
.
struct Message: Codable {
let from: String
let text: String
let attachments: [Attachment]
}
Inspecting the attachments of any message is a simple task.
let decoder = JSONDecoder()
let message = try decoder.decode(Message.self, from: json)
for attachment in message.attachments {
switch attachment {
case .image(let image):
// do something with the image
case .audio(let audio):
// do something with the audio
case .unsupported:
// ignore unsupported attachments
}
}
To support a new type of attachment, we would need to perform the following tasks:
- Implement a type for the new attachment.
- Add a new case to the
Attachment
enum and update itsCodable
implementation.
The second point is where the major drawback of this implementation lies. The Open/Closed Principle states:
Software entities should be open for extension, but closed for modification.
We should find a way to implement Attachment
so that it does not need modification when we have to support a new type of attachment.
Back to Any
We have no choice but to use Any
to store attachments if we want to avoid any future modifications.
struct Attachment {
let type: String
let payload: Any?
private enum CodingKeys: String, CodingKey {
case type
case payload
}
...
}
It may look like a step back, but bear with me, it’s not that bad.
The Codable
protocol was not made to work with Any
. We need a way to register attachment types.
Attachment.register(ImageAttachment.self, for: "image")
Attachment.register(AudioAttachment.self, for: "audio")
The implementation of register
should store the logic uniformly to decode and encode the given attachment type, keyed by the type name in the JSON. Recall how we decode an attachment:
try container.decode(ImageAttachment.self, forKey: .payload)
How the heck do we store that logic uniformly in a Dictionary
?
Type Erasure to the Rescue
To store the decoding and encoding logic uniformly, we have to erase type information. Let’s define the signature for our encoding and decoding closures.
typealias AttachmentDecoder = (KeyedDecodingContainer<CodingKeys>) throws -> Any
typealias AttachmentEncoder = (Any, inout KeyedEncodingContainer<CodingKeys>) throws -> Void
The AttachmentDecoder
closure takes a container and returns the result of decoding its contents.
The AttachmentEncoder
closure takes the attachment payload and encodes it using the given container.
Now we need two private static properties to store the decoders and the encoders respectively.
private static var decoders: [String: AttachmentDecoder] = [:]
private static var encoders: [String: AttachmentEncoder] = [:]
Finally, we can implement our register
method to store the decoding and encoding closures for a given attachment type.
static func register<A: Codable>(_ type: A.Type, for typeName: String) {
decoders[typeName] = { container in
try container.decode(A.self, forKey: .payload)
}
encoders[typeName] = { payload, container in
try container.encode(payload as! A, forKey: .payload)
}
}
A Better Codable Implementation
Now that we have a method to register attachment types, implementing Codable
is as easy as relying on the decoders
and encoders
static properties.
To decode an Attachment
, we access the value of the type
property and use it to find the corresponding decoder. Then, we use that decoder to decode the payload.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decode(String.self, forKey: .type)
if let decode = Attachment.decoders[type] {
payload = try decode(container)
} else {
payload = nil
}
}
To encode an Attachment
, we first encode its type and then find the corresponding encoder, which is then used to encode the payload.
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(type, forKey: .type)
if let payload = self.payload {
guard let encode = Attachment.encoders[type] else {
let context = EncodingError.Context(codingPath: [], debugDescription: "Invalid attachment: \(type).")
throw EncodingError.invalidValue(self, context)
}
try encode(payload, &container)
} else {
try container.encodeNil(forKey: .payload)
}
}
Example Use
It may surprise you, but inspecting the attachments of a message is not very different from how we did it with the enumeration solution.
It is important that we register the supported attachments before we do any decoding or encoding.
Attachment.register(ImageAttachment.self, for: "image")
Attachment.register(AudioAttachment.self, for: "audio")
As we can use a switch
statement for type casting, the rest is more or less the same.
let decoder = JSONDecoder()
let message = try decoder.decode(Message.self, from: json)
for attachment in message.attachments {
switch attachment.payload {
case let image as ImageAttachment:
// do something with the image
case let audio as AudioAttachment:
// do something with the audio
default:
// ignore unsupported attachments
}
}
To support a new attachment, we simply need to implement a new type for the attachment and register it. There is no need to modify the implementation of Attachment
.
Conclusion
Enumerations with associated values are one of my favorite Swift language features. However, I don’t think they are well suited for this use case.
Using Any
for the Attachment
payload and implementing a registration mechanism for attachment types is resilient to modifications while maintaining a similar development experience.