Using Property Wrappers with Codable

cross-posted from swift - Optional property wrapper used in Codable struct fails when value is missing - Stack Overflow

I've got a struct with a Double property that is represented as a String in the JSON coming from the backend.

struct Test: Codable {
    @StringRepresentation
    var value: Double?
}

Instead of implement init(from:) I've created the following property wrapper that takes advantage of LosslessStringConvertible to convert to and from String

@propertyWrapper
struct StringRepresentation<T: LosslessStringConvertible> {
    private var value: T?

    var wrappedValue: T? {
        get {
            return value
        }
        set {
            value = newValue
        }
    }
}

extension StringRepresentation: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if container.decodeNil() {
            value = nil
        } else {
            let string = try container.decode(String.self)
            value = T(string)
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        if let value = value {
            try container.encode("\(value)")
        } else {
            try container.encodeNil()
        }
    }
}

This works for

{
  "value": "12.0"
}

and

{
  "value": null
}

but fails when the property is missing

{
}

giving the error

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

Also, when encoding, I have to encode the null, otherwise I just get an {} empty object.

{
"value": null
}

i.e the value can't be omitted.

Another side-effect is that the generated init now takes a StringRepresentation instead of a Double.

I'm guessing this happens because the underlying StringRepresentation is not Optional.
How can I make it optional?

What are the best practices when using Property Wrappers with Codable?

2 Likes

Actually, I don't think this is anything to do with property wrappers. Instead, I think the problem is that you're trying to use a SingleValueDecodingContainer, which implies that there is exactly one value (not multiple values and not, as in your 3rd input case, no values).

Since the value is actually being presented as a JSON dictionary of one element, I think you can solve this by using a KeyedDecodingContainer instead. In that case, because your "value" property is Optional (Double?), you should correctly get nil when the value is missing.

Admittedly, this is just speculation — I haven't tried to verify my theory — but you should be able to try it easily enough.

But I don't have any Coding Keyes:

decoder.container(keyedBy: ???)

unkeyedContainer doesn't work either, since it's not an array.

But, I agree that using SingleValueDecodingContainer doesn't that much sense either.
If I change the implementation to not use a container at all, it still suffers from the same issue.
Which leads me to think that the main culprit is that I'm moving from a Double? to a StringRepresentation<T> -- loosing the Optional changes the behaviour of the default init(from:) and encode(to:).

extension StringRepresentation: Codable {
    init(from decoder: Decoder) throws {
        let string = try? String(from: decoder)
        value = string.flatMap(T.init)
    }

    func encode(to encoder: Encoder) throws {
        if let value = value {
            try "\(value)".encode(to: encoder)
        } else {
            // encodes to null, or {} if commented
            try Optional<String>.none.encode(to: encoder)
        }
    }
}

Here's a gist, complete with tests that you can you use in a Playground.

The way it is done in normal synthesizer is that:

  1. Generate CodingKeys if not already existed.
  2. Generate init(from:) if not already existed.

Further, the synthesizer uses appropriate variation of decodeIfPresent when the type is Optional, and normal decode otherwise. That's why it'd work nicely if your value is Double?--the synthesizer will use decodeIfPresent.

Now, what you need is something that is expanded to

struct Test: Decodable {
  var value: StringRepresentation<Double>?
}

but your code actually expands into (at least from codable synthesizer perspective)

struct Test: Decodable {
  var value: StringRepresentation<Double?>
}

And so the synthesizer will uses normal decode function, which will throw error if there is no key in the JSON data. The error is thrown before the initializer is called, so there's nothing from inside propertyWrapper that can be done.

We'll probably need a better system to express propertyWrapper with optional.

3 Likes

I ran into the same limitation of property wrappers and Codable trying to add a CustomCodable wrapper in sourcekit-lsp Move custom PositionRange encoding into a property wrapper by benlangmuir · Pull Request #167 · apple/sourcekit-lsp · GitHub. As @Lantua mentioned, the synthesized Codable implementation only uses decodeIfPresent and encodeIfPresent when the type of the property is optional. In this case, you would like the synthesis to use the type of the underlying value, but that might not be correct for all property wrappers, since the wrapper itself might contain state that needs to be serialized. It feels like a language change could improve this - e.g. a new attribute applied to the wrapper type to say: use the wrapped value for the purposes of deciding if the key should exist.

As @Lantua says, the problem is that you can't have a property wrapper with an optional backing storage type.

What's confusing about your example is that you've used value for both the property wrapper name and the inner property name of the private Double? property. If you imagine different names, it's much clearer that the optionality of the Double value doesn't make the JSON key optional.

I've come against this issue now with [CodableWrappers] (GitHub - GottaGetSwifty/CodableWrappers: A Collection of PropertyWrappers to make custom Serialization of Swift Codable Types easy). Has anyone found a workaround?

It's true it wouldn't be correct for all property wrappers, but if wrappedValue is Optional it seems clear that (en/de)codeIfPresent should be used. As it is, in my testing it fails with No value associated with key before it even touches the @propertyWrapper. That means it's impossible to have an Optional wrapped property that handles decoding properly, which seems like a clear bug not a missing future feature.

2 Likes

You can encodeNil and decodeNil for the optional value, so I'm not sure we can just call this a bug. The values will still round-trip if you use the same type for encoding and decoding. The problem is that this may not match what you want when talking to other tools that expect to be able to drop the key or that treat null distinctly from a missing key.

I don't think it's obvious that a property wrapper's internal state should be ignored just because it happens to wrap an optional value. That is clearly what we want for a codable wrapper, but not necessarily for other property wrappers.

TBC I'm not talking about always using the underlying value. That choice should/could be made by the Wrapper, but in my testing it's not even getting that far. That's what seems like a bug.

e.g. with this structure:

@propertyWrapper
struct MyWrapper: Codable {
    var wrappedValue: String?

    init(from decoder: Decoder) throws {
        wrappedValue = try? String(from: decoder)
    }
    func encode(to encoder: Encoder) throws {
        try wrappedValue.encode(to: encoder)
    }
}

struct MyType: Codable {
    @MyWrapper
    var value: String?
}

When you decode it

let jsonData = "{}".data(using: .utf8)!
do {
    let decoded = try JSONDecoder().decode(MyType.self, from: jsonData)
}
catch let error {
    print(error)
    // prints "keyNotFound(CodingKeys(stringValue: "value", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"value\", intValue: nil) (\"value\").", underlyingError: nil))"
}

It doesn't find the key so it throws before it makes the call to MyWrapper's init.

As far as I can tell it's impossible to make this structure work without overriding MyType's init(from decoder) :disappointed:

1 Like

"{}" will never be produced by encoding MyType. That's why I mentioned this issue is more about expectations when interacting with an external tool (or in this case, external data).

Sure, but that's a different issue from what I see as a bug.

Removing @MyWrapper from MyType makes it work as intended. At least with encode you get {"value":{}}, or {\"value\":null} if you encodeNil(). This corner case needs to be addressed, but at least it works and encodes to valid (though possible not desired) JSON.

Decoding just fails, even with valid JSON.

Use an extension on KeyedDecodingContainer and add an overload for decode method like this:

extension KeyedDecodingContainer {

    func decode<T>(_ type: StringRepresentation <T?>.Type, forKey key: Self.Key) throws -> StringRepresentation <T?> where T : Decodable {
        return try decodeIfPresent(type, forKey: key) ?? StringRepresentation <T?>(wrappedValue: nil)
    }
}

This will make sure that your synthesised property _value which is a non-optional is always created but in case the key is not present in json it only wraps around a nil value.

This works with synthesised Codable initialiser as this overloaded decode is used instead of its generic counterpart as it has a concrete type which is preferred over a generic one if present.

6 Likes

Yup, came across that solution in a different thread. My implementation: CodableWrappers/OptionalWrappers.swift at 1b449bf3f19d3654571f00a7726786683dc950f0 · GottaGetSwifty/CodableWrappers · GitHub

3 Likes

This is sweet! Have been scratching my head over and over. Overriding decode and using protocol with in init is a genius alternative. Thanks mate also @GetSwifty :slight_smile:

Just stumbled over this very problem and your excellent solutions, thanks @GetSwifty and @Pranshugoyal. I wonder whether there is anything going on in "Team Codable" to improve this situation in a more general way?

Yep, I agree with you