Debugging Codable
This post is brought to you by Emerge Tools, the best way to build on mobile.
Codable reminds me a lot of Mr.Henderson, a History teacher I encountered in High School. Though we by and large got along, there were a few bumps in my academic journey through his classroom. And it all had to do with citations.
To wit - do you know what style this is off the top of your head?
The citation: (Smith 67)
Works cited entry: Smith, John. The Example Book. Publisher, 2023.
Even better, can you pinpoint how it’s different from this?
Footnote: John Smith, The Example Book (Publisher, 2023), 67.
Bibliography entry: Smith, John. The Example Book. Publisher, 2023.
To me, tomato, toe-mah-toe, ya know? But to Mr.Henderson, using MLA citations when Chicago style were preferred instructed lit his fire. And it reflected on my assignments. His grading style was rigid, by the book and he expected a certain standard.
And here comes the tie in…🥁
Much like our friend Codable
tends to be.
Academically Correct, Practically Wrong
Consider the humble URL
:
struct Foo: Codable, CustomStringConvertible {
let url: URL
var description: String {
return url.absoluteString
}
}
let exampleOne: String = "http://www.example.com/index.html"
let mockResponse: Data = "{ \"url\": \"\(exampleOne)\" }".data(using: .utf8)!
let decoder = JSONDecoder()
do {
let model = try decoder.decode(Foo.self, from: mockResponse)
print(model)
} catch {
print(error)
}
✅
// Prints out http://www.example.com/index.html
For elementary examples, Codable
feels like outright magic! And in many respects, it certainly is. A simple Struct
, with a simple property, with a simple type - all created from a simple response.
Ah, if only the realities of dealing with server responses were simple, right?
Of course, we know that they aren’t. Just when you expect a response to have a value, a deploy occurs on a late Friday night and the next thing you know, Pager Duty erupts with an email blast:
🚫Fatal Error[Production]🚫:
Foo did not decode.
Expected a value for
url, and got "".
What happened? Our perfect response changed from a valid URL to an empty string. Now, Foo
craters and fails to decode.
Academically, this is absolutely correct. Cupertino & Friends™️ are telling no lies according to Codable
’s implementation and documentation. A blank string is not a URL, nor will it ever be.
let concatenatedThoughts = """
Perhaps more interestingly, the concept of what you actually want versus what you actually get is also a worthwhile topic to discuss.
For example, `mailto:jordan@swiftjectivec.com` will decode just fine in our example. It follows the URL Standard to the letter, complete with a valid spec and an EOF code point which parsers will acknowledge.
But is that what you actually were expecting? Probably not, and such unique cases require extra care to handle.
"""
But also, come on!? Why can’t we just have the model with an empty url
property? That would avoid many downstream effects in contrast to what a model failing to initialize probably would.
So, you endeavor to solve the problem and you might conclude, “Ah, this didn’t decode - maybe it should just be optional.”:
struct Foo: Codable, CustomStringConvertible {
let url: URL?
var description: String {
return url?.absoluteString ?? ""
}
}
…but that doesn’t get us anywhere either:
dataCorrupted(Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "url", intValue: nil)], debugDescription: "Invalid URL string.", underlyingError: nil))
At this point you’ve come to realize the reality of the situation, which is what many of us face: We simply can’t control what kinda B.S. the server is going to respond with.
That means “happy examples”, such as the one I led with, do not accurately represent the day-to-day realities most iOS developers will face. As such, here a few strategies we could use to help remedy the problem.
We could…
Use a Property Wrapper
Powerful and (surprisingly) simplistic, we could reason that, no matter what we receive for the value, if it doesn’t work - we simply want a nil
value:
@propertyWrapper
struct FailableURL: Codable {
public let wrappedValue: URL?
public init(from decoder: Decoder) throws {
wrappedValue = try? URL(from: decoder)
}
public init(_ wrappedValue: URL?) {
self.wrappedValue = wrappedValue ?? nil
}
}
And now, putting it all together with our previous example - Foo
decodes with a nil
value for its url
property:
struct Foo: Codable, CustomStringConvertible {
// ℹ️ Use our super awesome property wrapper
@FailableURL
var url: URL?
var description: String {
return url?.absoluteString ?? "Invalid URL"
}
}
let exampleOne: String = ""
let mockResponse: Data = "{ \"url\": \"\(exampleOne)\" }".data(using: .utf8)!
let decoder = JSONDecoder()
do {
let model = try decoder.decode(Foo.self, from: mockResponse)
print(model)
} catch {
print(error)
}
✅
// Prints out "Invalid URL" - and the model decodes properly.
Though, perhaps a more standard approach is preferrable. As such, we might…
Use Manual Codable Implementations
This route, which requires more code, is a little more understandable to future maintainers. A hand rolled encode
and decode
initializer are not uncommon in many Swift codebases:
struct Foo: Codable, CustomStringConvertible {
let url: URL?
var description: String {
return url?.absoluteString ?? "Invalid URL"
}
enum CodingKeys: String, CodingKey {
case url
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let urlString = try container.decodeIfPresent(String.self, forKey: .url)
url = URL(string: urlString ?? "")
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(url?.absoluteString, forKey: .url)
}
}
And here, we get the same result as the property wrapper previously gave us. All good. End post. Click publish. Tweet.
…Not.
Now, naturally, let’s throw a wrench in all of this. Recall what we just defensively programmed against: a whack value for a url
in the json response. But now, a new developer has joined the team, and a new project manager has too. And they are interested in our newest model, Box
:
struct Box: Codable, CustomStringConvertible {
let name: String
let showNewFeature: Bool
var description: String {
return name
}
}
Their first order of business? Box
no longer needs a showNewFeature
value at all, that feature is gonna roll out to everyone now! And, in a future update, you’ll accommodate their wishes.
But, since backend developers may assume that we mere iOS developers transcend space and time with our deploys much like they do - they go ahead and make the change and put it into production.
Now, our json response comes back like this:
{
"name" : "rick_rolled"
}
When it previously appeared like this:
{
"name" : "rick_rolled",
"showNewFeature" : true
}
And what happens? We face a new problem, depending on how we’ve chosen to implement Box
’s encoding and decoding strategy:
keyNotFound(CodingKeys(stringValue: "showNewFeature", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"showNewFeature\", intValue: nil) (\"showNewFeature\").", underlyingError: nil))
Swift is upset that showNewFeature
doesn’t appear anywhere at all, and the contract says that it should be there!
And again, Codable
is making academic sense here, but it’s also making our practical duties a little difficult. Since our backend team forgot that we have to go through a speedy, 100% always painless, definitely unbiased app review which treats every developer (large and small) the same each time we “deploy to production” - and they can just deploy to the internet immediately - we’ve got a problem.
This, too, could be easily solved using our two strategies above. Whether or not you manually write your Codable
implementations, the idea is the same - ensure you include a custom decode
function which leverages decodeIfPresent(_ type:forKey:)
. If we made another property wrapper, maybe it looks like this:
@propertyWrapper
struct DefaultToFalseIfMissing: Codable {
public let wrappedValue: Bool
public init(from decoder: Decoder) throws {
do {
wrappedValue = try Bool(from: decoder)
} catch {
wrappedValue = false
}
}
public init(_ wrappedValue: Bool?) {
self.wrappedValue = wrappedValue ?? false
}
}
extension KeyedDecodingContainer {
func decode(_ type: DefaultToFalseIfMissing.Type, forKey key: Self.Key) throws -> DefaultToFalseIfMissing {
return try decodeIfPresent(type, forKey: key) ?? DefaultToFalseIfMissing(nil)
}
}
That works. But now, let’s scale this problem up - because this is just one of many things that could go wrong. It’s great to know how to handle missing keys, but what if your json payload is over 4,000 lines long? What if the error is unclear, and we instead get something like this from lldb?
Consider:
do {
let bigHugeModel = try await APIClient.getDataDecoded()
} catch {
print(error.localizedDescription)
}
That could lead to something like this:
Printing description of error. valueNotFound. 1:
(DecodingError.Context) 1 = {
codingPath = 0 values {
buffer = {
_storage = (rawValue = 0x00000001bc0cb268 libswiftCore.dylib'type metadata for Swift.String)
}
}
debugDescription = {
_guts = {
_object = (_countAndFlagsBits = Swift. UInt64 @ 0x00006000036fado, _object = 0xd00000000000002d)
}
}
underlyingError = (error = 0×80000001809de40 (0x0000000180 e9de40) "value but found null instead.") {
some = (error = 0x80000001809de40 (0x00000001809de40) "value but found null instead.")
}
}
Hitherto, I used to solve these problems with a solemn heart and several prayers. These days, it pays to leverage the DecodingError
to see exactly what’s wrong. In particular, its Context
struct, which houses all sorts of useful information.
The following is kinda adapted from real world work, so you’d want to clean things up a bit - but you get the idea:
var debugText: String = ""
do {
let bigHugeModel = try await APIClient.getDataDecoded()
} catch DecodingError.dataCorrupted(let context) {
debugText = "\(context)"
} catch DecodingError.keyNotFound(let key, let context) {
debugText += "\nKey '\(key)' not found:\(context.debugDescription)."
debugText += "IncodingPath: \(context.codingPath)."
} catch DecodingError. valueNotFound (let value, let context) {
debugText += "\nValue '\(value)' not found: \(context.debugDescription)."
debugText += "\ncodingPath: \(context.codingPath)."
} catch DecodingError.typeMismatch(let type, let context) {
debugText += "\nType '\(type)' mismatch: \(context.debugDescription)."
debugText += "\ncodingPath: \(context.coding Path)."
} catch {
debugText = error.localizedDescription
}
I adapted my approach based off of what increasingly feels like the knowledge tome of the ancients in our new chatGPT-4 world, Stack Overflow. Using it, you’ll be able to debug Codable
issues with a much higher degree of specificity. Instead of the above inscrutable message (at least to me), you’d get something more manageable:
Value 'String' not found: Expected String value but found null instead..
codingPath: [_JSONKey (stringValue: "Index 13", inValue: 13), CodingKeys(stringValue:
"someProperty", intValue: nil),
CodingKeys (stringValue: "someProperty", intValue: nil)].
That tells us exactly we we’re facing and thus, what to fix. Though this is basic Swift error handling, it is extremely helpful to remember how robust the language has allowed error handling to become. For the most part, gone are the days of peeking into userInfo
dictionaries to learn more, or matching up random Integer error codes to documentation.
An Honest Approach
The bottom line isn’t that Codable
is finicky - in fact, quite the opposite! Codable
is rock solid in what it says it will do and how it will behave. It’s our own messes that are finicky.
Server responses change, a bad deploy occurs - normal, everyday stuff in our industry. The trick to making Codable
work for your model layer, in my opinion, is strictly one of thoughtful design: How should models behave when data is bad, corrupted or missing? It if it should flat out fail to decode altogether, then hey - you’re done!
For a lot of apps, though, that isn’t the right choice. Handling missing keys, assigning default values or switching that String returning as "true"
over to a vanilla bool
value are the decisions you will realistically be left with. And, fortunately, Swift offers robust options for every one of them. I hope you’ve picked up a few here today.
Until next time ✌️