Codable question

Is there a reason why we're guarding the decoding instead of returning nil?

enum Number: String, Decodable {
  case one = "ONE"
  case two = "TWO"
}

struct Foo: Decodable {
  let number: Number?
}

let whitespace = "{ \"number\": \" \" }"
let decoded = try JSONDecoder().decode(Foo.self, from: whitespace.data(using: .utf8)!)

I would expect number to be nil because the rawValue initialiser would return nil when passing an invalid string and the type of number is Optional. But instead, it throws a DecodingError saying "Cannot initialize Number from invalid String value ".

I guess it makes sense because we don't necessarily know the type, but still feels a bit weird to me.

cc @itaiferber

Hello,

Synthesized Decodable conformance prevents silent data loss.

" " is some value which can not be decoded as as Number. But who knows? Maybe " " is a very important value. Foo can't handle it, this must not be left unnoticed.

On the other side, the optional property in the Foo struct allows it to be decoded from "{ \"number\": null }". The Swift nil matches the JSON null, without any data loss.

The behavior you are looking for can be achieved with a custom implementation of iniit(decoder:), where the Number struct explicitly opts in for silent data loss:

enum Number: String, Decodable {
    case one = "ONE"
    case two = "TWO"
}

struct Foo: Decodable {
    let number: Number?
    
    private enum CodingKeys: String, CodingKey {
        case number
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let string = try container.decodeIfPresent(String.self, forKey: .number) {
            if let number = Number(rawValue: string) {
                self.number = number
            } else {
                // Unknown number: ignore (and lose data):
                self.number = nil
            }
        } else {
            self.number = nil
        }
    }
}

let whitespace = "{ \"number\": \" \" }"
let decoded = try JSONDecoder().decode(Foo.self, from: whitespace.data(using: .utf8)!)

Yeah I know, I was expecting the rawValue's init failure to be propagated up so number can be assigned nil instead (I mean, I made it Optional because I want it to be nil if anything goes wrong..). Basically above, but done automatically/synthesized.

This is because of how Codable is composed here between Optional<T> and T itself.

Optional<T>.init(from:) attempts to decode a T if the contents of the current container are not nil. It has no knowledge of what T is specifically, only that it is Decodable — in this case, it performs no introspection of T to try to figure out whether the value is valid or not.

In this case, T is Number, and Number.init(from:) is not a failable initializer, nor could it be. In Number.init(from:), there is no way to know the outer context of how things are being decoded — if number was declared as just let number: Number, returning nil would neither be valid, nor possible. So Number.init(from:) can't return nil, it can only throw when something goes wrong.

Between Optional not knowing what a Number should look like, and Number not being able to know or make use of the fact that it is inside of an Optional, there's nothing to do here but throw. Optional could attempt to catch certain errors to return nil instead of propagating, but that could easily result in a loss of information.

In general, it's better to err on the side of being conservative in terms of throwing away error information. If Optional did catch errors to perform this, but you actually wanted the opposite behavior, there would be no way to retrieve that error information back. With this scheme, it's at least possible to interject around decodeIfPresent(Number.self, forKey: .number) to catch errors or introspect in order to treat the result as nil, as @gwendal.roue offers above.

2 Likes

Thanks for the explanation, that makes sense!

1 Like