SingleValueCodable
A simple exercise in leverage
The new Codable
protocol is flexible enough to allow a different encoded representation from the in-memory representation which is a nice property to have in a serialization mechanism. Today I'm going to build SingleValueCodable
to automate that work when dealing with RawRepresentable
types.
The Setup
I want to encode information about various types of tea:
struct Tea: Codable {
struct Name: RawRepresentable, Codable {
var rawValue: String
static func custom(_ name: String) -> Name {
return self.init(rawValue: name)
}
static let earlGrey = Name(rawValue: "earlGrey")
static let englishBreakfast = Name(rawValue: "englishBreakfast")
}
enum CaffineLevel: Int, Codable {
case low
case medium
case high
case wakeUp
}
var name: Tea.Name
var caffine: Tea.CaffineLevel
}
So far nothing exciting. I'm nesting the types to prevent namespace pollution. Since types are a unit of composition in Swift, the tea name is represented as an explicit type. This helps prevent a large class of bugs because I can't accidentally treat a tea name as a string or vice-versa.
Encoding
I'm going to use this short snippet to dump the JSON as a pretty-printed string so we can examine the ergonomics of the encoding:
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let s = String(data: try! encoder.encode(teas), encoding: .utf8)!
print(s)
The naive implementation produces this output:
[
{
"caffeine" : 3,
"name" : {
"rawValue" : "earlGrey"
}
},
{
"caffeine" : 0,
"name" : {
"rawValue" : "green"
}
}
]
There are two problems with this:
- The name field is itself an object with a
rawValue
key. This takes longer to parse and bloats the size of the data for no gain. - The caffeine field is represented as an integer. That's great for our in-memory format but in this case I'm willing to trade some bytes in the serialization to gain readability. I'd also like the freedom to rearrange the underlying format or insert new entries without changing the meaning of previously encoded files.
Both of these can certainly be solved with a custom implementation of Codable
, but time to stop and think:
- Is serialization something I do often?
- Do I regularly need to control the serialization format?
- Do I forsee multiple single-value wrapper types?
The answer to these questions is 'yes' so I will take the time to create a protocol and protocol extension. I will pay the cost now to make myself more productive in the future.
The Protocol
protocol SingleValueCodable: Codable, RawRepresentable
where RawValue: Codable { }
The first step is a protocol that refines Codable
. Rather than introduce my own notion of a single-value wrapper I'll adopt the built-in RawRepresentable
which makes interoperating with enums and other frameworks easier.
The meat of the implementation will be in the protocol extension:
struct SingleValueCodableError: Error, LocalizedError {
var errorDescription: String?
}
extension SingleValueCodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(RawValue.self)
guard let value = Self.init(rawValue: rawValue) else {
throw SingleValueCodableError(errorDescription:
"Unable to decode a \(Self.self) from '\(rawValue)'")
}
self = value
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.rawValue)
}
}
Our implementation handles the boilerplate of working with a SingleValueContainer
but is otherwise unremarkable. Now what happens if Tea.Name
adopts SingleValueContainer
?
[
{
"caffeine" : 3,
"name" : "earlGrey"
},
{
"caffeine" : 0,
"name" : "green"
}
]
Much better. The name serializes as a JSON string with no wrapper object. Now I can consider what to do about caffeine.
Memory vs Serialization
For CaffeineLevel
it would be nice to keep the small in-memory representation but encode and decode a human-readable string. To do that I need to provide an implementation of RawRepresentable
that SingleValueCodable
can use:
enum CaffeineLevel: SingleValueCodable {
case low
case medium
case high
case wakeUp
var rawValue: String {
switch self {
case .low: return "low"
case .medium: return "medium"
case .high: return "high"
case .wakeUp: return "OK campers, rise and shine!"
}
}
init?(rawValue: String) {
switch rawValue {
case CaffeineLevel.low.rawValue: self = .low
case CaffeineLevel.medium.rawValue: self = .medium
case CaffeineLevel.high.rawValue: self = .high
case CaffeineLevel.wakeUp.rawValue: self = .wakeUp
default: return nil
}
}
}
Most uses of RawRepresentable
use property storage but the protocol doesn't care if we use a computed property instead. If I wanted to be slightly more permissive in the parsing I could use lowercased()
in the initializer and for the switch matchers.
After making this change the JSON looks nice and clean:
[
{
"caffeine" : "OK campers, rise and shine!",
"name" : "earlGrey"
},
{
"caffeine" : "low",
"name" : "green"
}
]
Extend All The Things
Speaking of extensions: I should hoist my debug encoder snippet into an extension on JSONEncoder
:
extension JSONEncoder {
static func debugEncoding<T: Codable>(of value: T) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return String(data: try! encoder.encode(teas), encoding: .utf8)!
}
}
print(JSONEncoder.debugEncoding(of: teas))
In theory this would be a nice extension of Encoder
but traveling that road requires generalized existentials.
Conclusion
This post was a small exercise in extending Codable
to more easily handle single-value wrapper types. I also showed how to go further and provide an alternate representation when serializing.
This blog represents my own personal opinion and is not endorsed by my employer.