Swift Failable Decodable
Problem
First things first. Lets understand the problem. To do this lets look at situation based on the real world issue that drove me to tackle this.
Recently while working on Pra I ran into a situation where it was fetching pull requests from GitHub and then failing to decode the JSON payload response containing the collection of pull requests, resulting in getting back zero pull requests. This is an unacceptable experience.
The decoding was implemented in the default fashion as follows:
public struct PullRequestSearchResponseNode: Decodable {
public let author: PullRequestSearchResponseAccount?
public let title: String
public let body: String
public let closedAt: Date
public let permalink: URL
...
}
public struct PullRequestSearchResponseDataSearch: Decodable {
public let nodes: [PullRequestSearchResponseNode]
public let pageInfo: PullRequestSearchResponsePageInfo
}
After some investigation I came to understand that it was failing due to not being able to properly decode one specific pull request because of effectively a type miss-match. For simplicity sake lets say the miss-match is happening because the PullRequestSearchResponseNode.closedAt
property has a value in some pull request payloads but is null
in others. This is a problem because our struct
that we are trying to decode into states that it is expecting closedAt
to always have a value.
Optionals
The correct, easiest, and most direct answer in the case of closedAt
is to adjust the PullRequestSearchResponseNode
struct so that the closedAt
property is an Optional
.
public struct PullRequestSearchResponseNode: Decodable {
public let author: PullRequestSearchResponseAccount?
public let title: String
public let body: String
public let closedAt: Date?
public let permalink: URL
...
}
The Catch
Using an Optional
is great. It fixes the problem and Pra is able to successfully decode all the pull requests again. However, there is one major catch. Sadly as much as we feel we know the specifications for a JSON payload it is likely we will miss something or maybe the JSON payload will change over time. Given this will inevitably happen we can't have that breaking our applications.
Possible Paths
So we need to come up with some way of handling the fact that we will miss something in the specification or that the specification will change.
All Properties Optional
One approach, not that I recommend it, is to make every property on the decodable struct an Optional
. This would address a chunk of possible issues in decoding but not all possible issues. Also, it has the massive downside of then forcing the rest of the app to deal with both Optional
paths everywhere in the code. Even if it is a property that will always be present.
Optional Wrapper
Another approach is to create a generic struct around T
that wraps an option property of type T
and provides decoding implementation. This could look something like the following:
public struct Failable<T: Decodable>: Decodable {
let wrappedValue:T?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.wrappedValue = try? container.decode(T.self)
}
}
We would use this as follows:
public struct PullRequestSearchResponseDataSearch: Decodable {
public let nodes: [Failable<PullRequestSearchResponseNode>]
public let pageInfo: PullRequestSearchResponsePageInfo
}
This is nice as it effectively makes it so that we can make decoding failable at any of the various object boundaries. In the scenarios where decoding fails for some reason our wrappedValue
would simply be null
. This is definitely a step in the right direction. However, it has one major drawback. We have no idea why the decoding failed.
Result based Failable Decodable
Enter the Result
type. This is exactly it's purpose, to manage optionality while also maintaining an associated error. So, instead of building a wrapper struct we can simply extend Result
to implement the Decodable
protocol and implement the constructor to handle appropriately assigning the success
state in successful decodes, and assigning the failure
state when decoding fails. This looks as follows:
extension Result: Decodable where Success: Decodable, Failure == DecodingError {
public init(from decoder: Decoder) throws {
do {
let container = try decoder.singleValueContainer()
self = .success(try container.decode(Success.self))
} catch let err as Failure {
self = .failure(err)
}
}
}
This is used as follows:
public struct PullRequestSearchResponseDataSearch: Decodable {
public let nodes: [Result<PullRequestSearchResponseNode, DecodingError>]
public let pageInfo: PullRequestSearchResponsePageInfo
}
This is great as we get the same ability to make objects failable decodables at any of the object boundaries but we also maintain all of the decoding errors to let us know why decoding failed for a given node.
Conclusion
If you are dealing with this situation you probably want to use the Result based Failable Decodable approach. I used this technique to solve the problems described in the Pra v1.4.0 Announcement. It facilitated collecting the decode failures and building the debug report so users can send them back to us.