Cleaner Way to Decode Missing JSON Keys?

Ah, thanks for the example. Codable synthesis does use decodeIfPresent for all optional properties, but importantly, annotating a variable with a property wrapper creates a new property with a different underlying type.

When you write

@Crypto var name: String?

the compiler produces the equivalent of

private var _name: Crypto<String?>
var name: String? {
    get { return _name.wrappedValue }
    set { _name.wrappedValue = newValue }
}

In Codable synthesis in the compiler, wrapped properties are treated specially: when the compiler finds the name property, it looks up the backing property for the wrapper:

static std::tuple<VarDecl *, Type, bool>
lookupVarDeclForCodingKeysCase(DeclContext *conformanceDC,
                               EnumElementDecl *elt,
                               NominalTypeDecl *targetDecl) {
  for (auto decl : targetDecl->lookupDirect(
                                   DeclName(elt->getBaseIdentifier()))) {
    if (auto *vd = dyn_cast<VarDecl>(decl)) {
      // If we found a property with an attached wrapper, retrieve the
      // backing property.
      if (auto backingVar = vd->getPropertyWrapperBackingProperty())
        vd = backingVar;

    // ... use `vd` to look up the type of the property, and use that
    // for synthesis.

This means that for the purposes of encoding and decoding, the compiler will encode and decode _name, not name. This is crucial, because it's what allows Crypto to intercept encoding and decoding of the property with its own Codable conformance.

But it's important to note that the type of _name is Crypto<String?>, which is not Optional. You can see this if you try to implement init(from:) on Item the same way that you expect the compiler to:

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)

    // `name` is a computed property, which can't be assigned to in an initializer.
    // You have to write to `_name`, which is the real property.
    _name = try container.decodeIfPresent(Crypto.self, forKey: .name)
    // ❌ error: Value of optional type 'Crypto?' must be unwrapped to a value of type 'Crypto'
}

If you want to write this, you'll need to support some type of fallback:

struct Item: Codable {
    @Crypto var name: String?
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        _name = try container.decodeIfPresent(Crypto.self, forKey: .name) ?? Crypto(value: nil)
    }
}

This does work the way that you might expect:

let data = """
    {}
""".data(using: .utf8)!
let item = try! JSONDecoder().decode(Item.self, from: data)
print(item) // => Item(_name: main.Crypto(value: nil))

While this behavior is somewhat pedantically correct, the synthesis behavior might not be terribly useful. Theoretically, the compiler could be augmented to do this automatically — it can look through the type of the property wrapper to see if the contained type is Optional, and use a decodeIfPresent then, though this does get complicated in the face of multiple nested wrappers. The biggest risk would be the behavior change.

This might be worth filing as a bug for consideration.

2 Likes