Let’s talk about Codable
. This is a quite nice solution to encoding and parsing JSON, that is used everywhere in Swift. It is quite simple, but sometimes you need a little more than just “parse this easy thing”. I recently bumped into something like it and thought that the solution could be helpful for others.
How exactly did I meet this? I reverse engineered KEF API, to be able to control the speakers from the Stream Deck +. This project is quite successful, but it’s a story for another time. While investigating API I found out this piece of JSON (this was part of the current volume request):
"value": {
"type": "i32_",
"i32_": 40
}
After some more tinkering I found out that there are several types: standard string_
, bool_
, and some custom ones, like kefPhysicalSource
.
This looks a lot like an enum with associated value, or some kind of union-like structure. Unfortunately, if I try to encode enum with associated values in Swift, I get [totally another structure]. This means that we need to create custom coding.
Let’s figure out the structure first. It contains one constant property: type
, that defines which property name to use for the value, and its type. For i32_
it will be an integer value, a boolean for bool_
and string for the string_
. Custom values like kefPhysicalSource
can be encoded in some different way. Also we will need to create CodingKeys
enum with all the options.
In theory this is an easy code to write. For decoding, we just parse type
and then create a switch
based on its value. Every time we need to parse another type, we add a case
to the switch
.
public struct RawValue: Codable {
public var value: Any // Let's skip this Any for a while
enum CodingKeys: String, CodingKey {
case type = "type"
case int32 = "i32_"
case bool = "bool_"
// ... other cases
}
public 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 "i32_":
value = try container.decode(Int32.self, forKey: .type)
case "bool_":
value = try container.decode(Int32.self, forKey: .type)
// ... other cases
}
}
public func encode(to encoder: Encoder) throws {
// Here the encoding will be :)
}
}
We will also need to find out a way of encoding the value correctly. For example, we can check the type of the value
, and choose the name of the property based on it.
var container = encoder.container(keyedBy: CodingKeys.self)
switch value {
case let value as? Int32:
try container.encode("i32_", forKey: .type)
try container.encode(value, forKey: .int32)
// similar code for every type
}
This is a direct solution, and it is not really expandable. The code will be changing every time we need to parse a new type, that will result in more convoluted code, extra bugs and problems. As the code for all types is similar, it will be copy-pasted, that will lead to even more errors. Can we use Swift features to simplify this solution?
Using Swift
Let’s think. First of all, we would like to limit the types that we can use with the RawValue
. For example, we would like to allow String
, Int32
, Bool
, custom PhysicalSource
, but not UIView
or UInt64
. This can be made by using the marker protocol (I’ll skip the PhysicalSource
for now, we’ll return to it later):
public protocol RawValueType {
}
extension String: RawValueType {
}
extension Int32: RawValueType {
}
extension Bool: RawValueType {
}
Now we can constrain RawValue.value
to this protocol which will do the trick:
public struct RawValue<Type: RawValueType>: Codable {
public var value: Type?
// Coding Keys
// Decoding part
// Encoding part
}
Now we can’t create a RawValue
with a non-parsable type.
Next task is to put JSON property names to a place, where we can use them for everything. The ideal place will be inside already created extensions:
public protocol RawValueType {
static var encodingType: String { get }
}
extension String: RawValueType {
public static let encodingType: String = "string_"
}
extension Int32: RawValueType {
public static let encodingType: String = "i32_"
}
extension Bool: RawValueType {
public static let encodingType: String = "bool_"
}
Next task is to deal with the CodingKeys
. Usually it contains cases with JSON property names, but we need to use different names for different types. So ideally we’d like to use something like this:
enum CodingKeys: CodingKey {
case type
case value(type: String)
}
Looks great, but now we need to implement the requirements for the CodingKey
protocol. Usually we do this by providing rawValues
(String
or Int
), but in our case we have to do it manually:
// We don't need these two, because we are using strings for the keys
init?(intValue: Int) { nil }
var intValue: Int? { nil }
init?(stringValue: String) {
self = stringValue == "type" ? .type : .value(type: stringValue)
}
var stringValue: String {
switch self {
case .type: return "type"
case .value(let type): return type
}
}
Now our CodingKeys
will support custom types, and we will not need to update it ever again.
Let’s try to write the encoder now:
public struct RawValue<Type: RawValueType>: Codable {
public var value: Type?
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Type.encodingType, forKey: .type)
let valueKey = CodingKeys.value(type: Type.encodingType)
try container.encode(value, forKey: valueKey)
}
}
Looks awesome, but it will not work for now, because the type of our value is RawValueType
, that is not Encodable
. Let’s make it comply, it only requires the addition of the conformance itself, that’s all.
public protocol RawValueType: Codable { ... }
OK, encoding works now, we need to think about the decoding:
public struct RawValue<Type: RawValueType>: Codable {
public var value: Type?
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedType = try container.decode(String.self, forKey: .type)
guard decodedType == Type.encodingType else {
let message = "\(decodedType) != \(Type.encodingType) :("
throw DecodingError
.dataCorruptedError(in: container, debugDescription: message)
}
let valueKey = CodingKeys.value(type: Type.encodingType)
value = try container.decode(Type?.self, forKey: valueKey)
}
public func encode(to encoder: Encoder) throws { ... }
}
This code will never change and adding a new type is easy, we only need to make it implement RawValueType
protocol.
Wait, we’ve forgotten the PhysicalSource
. It is different because it is custom, and may not implement Codable
out of the box, in which case we will need to implement it ourselves.
extension PhysicalSource: RawValueType {
public static let encodingType: String = "kefPhysicalSource"
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
switch string {
case "usb": self = .usb
case "standby": self = .standby
default: self = .unsupported
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .usb: try container.encode("usb")
case .standby: try container.encode("standby")
case .unsupported:
throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Can't encode unknown source"))
}
}
}
Result
In this article we went from the direct approach, that is quite unsupportable to the nice solution that uses Swift features, will be statically checked on compilation, and will not require any maintenance. I’d say that this is a success. Did I miss something? ‾\(ツ)/‾
Full code can be found here: