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?
(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:
Optional
property, it generates a call to encodeIfPresent(_:forKey:)
/decodeIfPresent(_:forKey:)
, which skips encoding the value altogether if it is nil
, and crucially, skips decoding the value altogether if the key is not present in the keyed containerOptional
property, it instead generates a call to encode(_:forKey:)
/decode(_:forKey:)
, which unconditionally encode the value even if nil
, and unconditionally requires the key to be present on decodeIt'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.)