[Pitch] Nullable and Non-Optional Nullable Types

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:

  1. If the compiler encoded and decoded the wrapped value instead of the property wrapper itself, the property wrapper's own init(from:) and encode(to:) would never get called, preventing the possibility of using a property wrapper from customizing Codable conformance in the first place
  2. Even if a property wrapper wraps an Optional value, the property wrapper itself is not optional — if you were to decodeIfPresent(PotentiallyMissing<Int>.self, forKey: .i) and got back nil, what value could be reasonably assigned to _i? (The compiler could theoretically know to try to assign PotentiallyMissing(wrappedValue: nil), but this gets really subtle and tricky)

With this in mind, you can see that:

  1. On decode, the field is asserted to be present (even if nil): the decode call checks for the .i key before ever calling into PotentiallyMissing<T>.init(from:), so an error is thrown before any decoding actually happens
  2. On encode, the field is also asserted to be present (even if nil), which means that if nothing at all is encoded by PotentiallyMissing<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.

:warning: HOWEVER: :warning:

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:

  1. Module A, provides PotentiallyMissing<T>
  2. Module B imports Module A, and has a type which encodes and decodes using PotentiallyMissing<T>
  3. 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.

Use at your own risk.

3 Likes