One last (🚨 important 🚨) note:
The reason a property wrapper can't directly handle this case is because the check for the presence of a key is done one level up in the containing type, and when you wrap a variable in a property wrapper, it's the type of the property wrapper that gets encoded and decoded.
In the case of S.i
, the actual type being encoded and decoded is PotentiallyMissing<Int>
, not the Int??
wrapped value. This means that when the compiler synthesizes Encodable
and Decodable
conformances, the calls being made are encode(_:forKey:)
and decode(_:forKey:)
, not the IfPresent
variants, because the type isn't actually Optional
.
This means that struct S
actually looks like:
struct S: Codable {
@PotentiallyMissing var i: Int??
private enum CodingKeys: String, CodingKey {
case i
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
_i = try container.decode(PotentiallyMissing<Int>.self, forKey: .i)
}
func encode(to encoder: Encoder) throws {
var container = try encoder.container(keyedBy: CodingKeys.self)
try container.encode(_i, forKey: .i)
}
}
If you want additional justification for why this behavior is necessary, consider:
- If the compiler encoded and decoded the wrapped value instead of the property wrapper itself, the property wrapper's own
init(from:)
andencode(to:)
would never get called, preventing the possibility of using a property wrapper from customizingCodable
conformance in the first place - Even if a property wrapper wraps an
Optional
value, the property wrapper itself is not optional — if you were todecodeIfPresent(PotentiallyMissing<Int>.self, forKey: .i)
and got backnil
, what value could be reasonably assigned to_i
? (The compiler could theoretically know to try to assignPotentiallyMissing(wrappedValue: nil)
, but this gets really subtle and tricky)
With this in mind, you can see that:
- On decode, the field is asserted to be present (even if
nil
): thedecode
call checks for the.i
key before ever calling intoPotentiallyMissing<T>.init(from:)
, so an error is thrown before any decoding actually happens - On encode, the field is also asserted to be present (even if
nil
), which means that if nothing at all is encoded byPotentiallyMissing<T>.encode(to:)
, the key still needs to be written out, and so an empty object is inserted to allow the key to be present
The reason @pyrtsa's suggestion works is that it interjects an overload for PotentiallyMissing<T>
one level up at the actual encode
/decode
callsites, and does the work there, so that you can map a missing key to an actual PotentiallyMissing<T>
value and vice versa.
HOWEVER:
You must be extremely careful with this approach — both in using it, and suggesting it to others. These method additions are statically dispatched, and so will only work for PotentiallyMissing<T>
within the modules where these overloads are visible (and cannot work "retroactively"). In other words, they're likely to only work within your module.
Consider:
- Module A, provides
PotentiallyMissing<T>
- Module B imports Module A, and has a type which encodes and decodes using
PotentiallyMissing<T>
- Module C (your module) imports Module B, and also has a type which encodes and decodes using
PotentiallyMissing<T>
, and adds the listed overloads
Code from Module B will encode PotentiallyMissing<T>
values one way, and your module will encode those same values another way! This is extraordinarily subtle and fragile, and can lead to inconsistent results, even within the same type hierarchy.