iosjsonswiftjsondecoderproperty-wrapper

A property wrapper causes JSONDecoder to fail when property is not in the json string


I have a data model class with custom rules to deserialize from json. Specifically, the property flexibleProp can be defined in JSON as either String? or Int?. On my model class it should always be Int?. I have implemented a propertyWrapper to support custom deserialization rules:

class PersonInfo: Codable {
    var regularProp: String?
    @FlexibleInt varflexibleProp: Int?
    
    init() {
    }
}

@propertyWrapper struct FlexibleInt: Codable {
    var wrappedValue: Int?
    
    init() {
        self.wrappedValue= nil
    }
    
    init(fromdecoder: Decoder) throws{
        letcontainer = trydecoder.singleValueContainer()
        ifletvalue = try? container.decode(String.self) {
            wrappedValue= Int(value)
        } elseifletintValue = try? container.decode(Int.self) {
            wrappedValue= intValue
        } else{
            wrappedValue= nil
        }
    }
    
    func encode(toencoder: Encoder) throws {
        varcontainer = encoder.singleValueContainer()
        trycontainer.encode(wrappedValue)
    }
}

let decoder = JSONDecoder()
let payload1 = "{ \"flexibleProp\": \"123888\", \"regularProp\": \"qqq\" }"
letperson1= trydecoder.decode(PersonInfo.self, from: payload1.data(using: .utf8)!)
let payload2 = "{ \"flexibleProp\": \"\" }"
letperson2= trydecoder.decode(PersonInfo.self, from: payload2.data(using: .utf8)!)
let payload3 = "{ \"flexibleProp\": \"sss\" }"
letperson3= trydecoder.decode(PersonInfo.self, from: payload3.data(using: .utf8)!)
let payload4 = "{ }"
letperson4= trydecoder.decode(PersonInfo.self, from: payload4.data(using: .utf8)!)  // FAILS HERE <------------

And it works in most cases correctly, only the last test case fails, when the flexibleProp property is completely missing in the json string (key and value are not defined):

▿ DecodingError
  ▿ keyNotFound : 2 elements
    - .0 : CodingKeys(stringValue: "flexibleProp", intValue: nil)
    ▿ .1 : Context
      - codingPath : 0 elements
      - debugDescription : "No value associated with key CodingKeys(stringValue: \"flexibleProp\", intValue: nil) (\"flexibleProp\")."
      - underlyingError : nil

I'm having no issues with the other String? property regularProp, when missing in json, it stays nil in the result, but for the flexibleProperty it fails to deserialize the whole object instead of just keeping the property nil. What is the best way to tell JSONDecoder to properly handle that property when it's not defined in the json string?


Solution

  • (Note: this answer is adapted from a similar post of mine on the Swift forums, explaining the behavior.)

    Unfortunately, a property wrapper cannot influence Codable synthesis to allow a value for a specific key to be omitted entirely.

    When you write

    @FlexibleInt varflexibleProp: Int?
    

    the compiler generates the equivalent of

    private var _varflexibleProp: FlexibleInt
    var varflexibleProp: Int? {
        get { return _varflexibleProp.wrappedValue }
        set { _varflexibleProp.wrappedValue = newValue }
    }
    

    Because _varflexibleProp is the "real" property (and varflexibleProp is just a computed property), for the purposes of encoding and decoding, the compiler will encode and decode _varflexibleProp. This is crucial, because it's what allows FlexibleInt to intercept encoding and decoding of the property with its own Codable conformance at all.

    But it's important to note that the type of _varflexibleProp is FlexibleInt, which is not Optional — it just contains an Optional inside of it. This is important, because Optional values are special for Codable synthesis:

    It's that last point that's problematic: because _varflexibleProp is not Optional, the compiler attempts to initialize it as

    init(from decoder: Decoder) throws {
        let container = try container(keyedBy: CodingKeys.self)
    
        _varflexibleProp = try container.decode(FlexibleInt.self, forKey: .varflexibleProp)
        // ...
    }
    

    decode(_:forKey:) has to check whether a key is present before anything can even be decoded, and so in your case, an error is thrown before FlexibleInt.init(from:) is ever called — because there isn't even any data for you to possibly decode from.

    If the compiler did try to use decodeIfPresent(_:forKey:), it would have to provide some sort of default value for _varflexibleProp, which isn't always obvious how to do:

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
    
        // `varflexibleProp` is a computed property, which can't be assigned to in an initializer.
        // You have to write to `_varflexibleProp`, which is the real property.
        _varflexibleProp = try container.decodeIfPresent(FlexibleInt.self, forKey: .varflexibleProp)
        // ❌ error: Value of optional type 'FlexibleInt?' must be unwrapped to a value of type 'FlexibleInt'
    
        // ^ oops! what value do we assign to _varflexibleProp?
    }
    

    Here, it might seem obvious that _varflexibleProp should be assigned a value of FlexibleInt(), but the compiler has no way of knowing whether that has the semantics you want it to have (e.g., what if it has side effects?). In the linked Swift Forums thread, the property wrapper author didn't give it a zero-argument initializer, but instead had a single-argument initializer that took a wrappedValue; this can very quickly devolve into a guessing game of how to initialize this non-Optional property, which may not be safe for the compiler to do at all.


    Ultimately, this behavior isn't related to JSONDecoder specifically, but to how the compiler synthesizes Codable conformance. The workaround for now would be to implement PersonInfo.init(from:) directly, using decodeIfPresent(_:forKey:) yourself and initializing _varflexibleProp with a default value.

    There's a bit more discussion in the thread about how this could possibly be handled in the future, so it's worth a read if you're curious. (And, now that macros are coming to Swift 5.9, it's possible that in the future, Codable conformance could be pulled out of the compiler the way that it's written now, and instead implemented as a macro — and if so, toggles could be added to better influence how that happens.)