With Swift 4 we received a new api to help automagically encode and decode types. By conforming to Codable
(A protocol composed of Encodable
and Decodable
) we can convert types to and from different formats, such as json or property lists.
There are a number of types that already conform to Codable
out-of-the-box. At the time of writing these are: String
, Int
, Double
, Date
, Data
and URL
. The nice part about Codable
is, if your type is made up of other Codable
s then you get Codable
for free. So that means you can define types like:
struct User: Codable {
let name: String
let url: URL
}
and
enum AuthState: String, Codable {
case loggedIn
case loggedOut
}
And Codable
just works - no extra code is required.
You can store AuthState
for example with something like:
let state: AuthState = .loggedIn
let data = try JSONEncoder().encode(state)
data.write(to: configFile)
How great is that?!
The enum is interesting. The reason this works is because we have given it a RawValue
of String
. Swift can use that to infer a String
for each case and since String
is Codable
we get it for free, but what if we wanted to change the enum slightly?
enum AuthState: Codable {
case loggedIn(User)
case loggedOut
}
We decide its better to store the logged in user with the logged in state and since User
is Codable
this should just work too right?
Unfortunately this isn’t the case (no pun intended)… the enum is no longer RawRepresentable
and since there is no RawValue
anymore Swift doesn’t know how to encode/decode each case.
To make this change work we will need to implement Codable
ourselves. Lets start by creating the CodingKey
s the enum will use to provide a unique key for each case:
extension AuthState {
private enum CodingKeys: String, CodingKey {
case loggedIn, loggedOut
}
}
Next, lets implement the Encodable
part:
extension AuthState {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .loggedIn(let user): try container.encode(user, forKey: .loggedIn)
case .loggedOut: try container.encode(CodingKeys.loggedOut.stringValue, forKey: .loggedOut)
}
}
}
You can see the Encodable
part benefits from exhaustive switching, we encode cases with an associated value using their CodingKey
as the ‘key’ and the associated value as the ‘value’. For cases without an associated value we use the CodingKey
as both the ‘key’ and ‘value’.
Finally lets look at the Decodable
code:
extension AuthState {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let user = try container.decodeIfPresent(User.self, forKey: .loggedIn) {
self = .loggedIn(user)
} else if let _ = try container.decodeIfPresent(String.self, forKey: .loggedOut) {
self = .loggedOut
} else {
throw DecodingError.valueNotFound(AuthState.self, .init(codingPath: container.codingPath, debugDescription: ""))
}
}
}
For Decodable
we unfortunately aren’t able to leverage exhaustive switching. So we need to attempt to decode each case one by one. For cases with an associated value this means attempting to decode the associated value for the key. For cases without associated values we attempt to decode the String
value. Finally if nothing succeeds we throw a DecodingError
.
This is perfectly fine and will work as-is, so we could just leave it here and be done. While the Encodable
code is about as concise as we can make it this Decodable
code is a little wordy.
Let's look at how we might clean this up a little.
If we look at the current solution to decoding its essentially the same as iterating through all possible cases attempting to decode the required data for each case. It stops as soon as a something is decoded, if nothing was decoded we throw an error.
Lets build something that uses that pattern:
typealias Decode<Result: Decodable, Key: CodingKey> = (KeyedDecodingContainer<Key>) throws -> Result?
func decode<Result: Decodable, Key>(using container: KeyedDecodingContainer<Key>, cases: [Decode<Result, Key>]) throws -> Result {
guard let result = try cases.lazy.flatMap({ try $0(container) }).first
else { throw DecodingError.valueNotFound(Result.self, .init(codingPath: container.codingPath, debugDescription: "")) }
return result
}
This allows us to supply an array of closures, each one uses the container to attempt to decode the data for its specific case. Using this we end up with Decodable
code that looks like this:
extension AuthState {
init(from decoder: Decoder) throws {
self = try decode(using: decoder.container(keyedBy: CodingKeys.self), cases: [
{ container in
guard let value = try container.decodeIfPresent(User.self, forKey: .loggedIn) else { return nil }
return .loggedIn(value)
},
{ container in
guard let _ = try container.decodeIfPresent(String.self, forKey: .loggedOut) else { return nil }
return .loggedOut
},
]
)
}
}
Now we have something that does the same thing as the original pattern, and while it now takes care of the error handling for us it doesn’t really read any better (in fact you could argue its actually worse 😭).
What we want to end up with is a function that fits our Decode
signature. However because we need to provide more data we will need to create a curried function that eventually returns our Decode
function.
The code we already have actually covers our two scenarios, so we can use them as the basis for our new curried functions.
Here is our function for dealing with cases without associated values. We are passing in the case we want, assuming decoding is successful, as well as the CodingKey
it should be stored under.
func value<Result: Decodable, Key: CodingKey>(_ case: Result, for key: Key) throws -> Decode<Result, Key> {
return { container in
guard let _ = try container.decodeIfPresent(String.self, forKey: key) else { return nil }
return `case`
}
}
Now we can use the statement value(AuthState.loggedOut, for: .loggedOut)
in place of the closure for this case.
Handling cases with associated values lets us use one of Swifts many cool features. Cases with associated values behave like constructor functions and can be referenced the same way. An example should make this clearer:
// notice we have omitted the associated value for this case
AuthState.loggedIn // (User) -> AuthState
Well that is convenient, thats the exact function we need to infer all the extra details from the original closure!
func value<Result: Decodable, Key: CodingKey, T: Decodable>(_ function: @escaping (T) -> Result, for key: Key) throws -> Decode<Result, Key> {
return { container in
guard let value = try container.decodeIfPresent(T.self, forKey: key) else { return nil }
return function(value)
}
}
Now our associated value closure can be replaced with value(AuthState.loggedIn, for: .loggedIn)
.
Putting it all together re can rewrite our Decodable
code:
extension AuthState {
init(from decoder: Decoder) throws {
self = try decode(using: decoder.container(keyedBy: CodingKeys.self), cases: [
value(AuthState.loggedIn, for: .loggedIn),
value(AuthState.loggedOut, for: .loggedOut),
]
)
}
}
I think that looks much nicer now.
It’s worth noting that while this works great for local serialization it may not be in the format you need if you intend on sending this to a server. For instance our cases, when converted to json become:
.loggedIn(User(name: "Ian Keen", url: URL(string: "http://iankeen.tech/")!))
becomes:
{"loggedIn":{"name":"Ian Keen","url":"http://iankeen.tech/"}}
.loggedOut
becomes:
{"loggedOut":"loggedOut"}
You may need to tweak the format a little to suit your server api requirements.
Its a shame this doesn’t fit into the automagical Codable
bucket, hopefully it is something we will get in the future. For now I think this is a decent solution for keeping this cleaner.
To see the full code checkout this playground.