CodingKeyPath: Add support for Encoding and Decoding nested objects with dot notation

I disagree. CodingKeys was a nice hack to enable users to tweak synthesis within the boundaries of swift syntax at the time, but it is still kind of a weird system that is not that easy to explain, and—as evidenced by this proposal—hard to extend/enhance in a way that "fits" nicely.

Removed? No. But eventually deprecated and users directed to a simpler, more expressive system? Absolutely possible with no source breakage required.

Since this just affects synthesis, even a new system to customize keys etc. would still use the same coding apis in the end, so there would not be two systems, and types using the old form could happily work together with others using some new syntax.

3 Likes

I totally agree with this. Codable is amazing when you have full control of the situation, but it's still far from ideal when dealing with the ugly real world APIs, to the point that I still prefer GitHub - Anviking/Decodable: [Probably deprecated] Swift 2/3 JSON unmarshalling done (more) right most of the time because it give you a consistent and extensible API that works on every case. I wish that Codable could keep its amazing convenience with a better solution for extensibility/configuration.

But if we need to move forward with this feature I prefer to bake the strategy specifically to JSON*Coder

A big missing one I hit is default values - omitting a value from serialized results and setting a default when deserializing if the value is missing. It doesn't appear you can work around this with property wrappers, you have to write a full Codable implementation.

I've hit cases where a top-scoped configuration for encoding is not possible, such as when one piece of Data should be encoded as base64 , and another should be encoded as hexadecimal.

You also have problems when serializing into more flexible formats, such as XML.

In my CBOR encoder, there is a challenge that there is the ability to tag the semantics of a bit of data, for instance this number should be interpreted as milliseconds since epoch or this object (dictionary) should be interpreted as a vcard. You could both decide that a piece of data you are defining should be sent tagged, or want to control whether a child element sends that tag (since the semantics are already known and parsers might choke if the tag was sent when they weren't expecting it). I can imagine a way to set the tag on a type (define a protocol which gives me the tag value and have the types implement that protocol, but defining that it should appear only in certain contexts is not feasible.

There isn't guidance today on how to maintain an interoperable data format, for instance what changes are expected to work round trip through an Encoder/Decoder pair. Example here - if I reorder the declaration of properties within my type, could that be a breaking change for certain encoders which expect keys to be encoded and decoded in the same order?

In addition, there isn't guidance on how to support backward/forward compatibility if you need to change the encoding of a type (such as to deal with new data, or if I find out I had breakage in the field through some change like the aforementioned reordering of properties).

Recall the stated aims for Codable:

  • It aims to provide a solution for the archival of Swift struct and enum types
  • It aims to provide a more type-safe solution for serializing to external formats, such as JSON and plist

Obviously, these facilities may also be useful for ingesting JSON that's not serialized from Swift models but from other sources. But a design that is capable of dealing with arbitrary third-party APIs requires a degree of extensibility and configuration significantly beyond what's necessary or most usable for the stated primary aims.

There's obviously no need to make it unnecessarily difficult to work with third-party data that happens to be well behaved. However, when it comes to formats that would require a whole laundry list of additional features to be added to Swift, I think it's worth thinking carefully about the intended scope of the problem that Codable is meant to address (i.e., what features we should add to Codable) versus something that's altogether different (i.e., a separate set of facilities, perhaps even an entire library).

2 Likes

AlamoFireObjectWrapper has a feature similar to this. In the normal case when decoding you tell the decoder: decode my object of this Type. In the alternate case you tell the decoder: decode my object of this Type at this Keypath. It's simple and works well. This is one pain point in the current implementation of Decodable. I was recently converting some code from AlamoFireObjectWrapper to Decodable (since the release of Alamofire 5 supports Decodable better) and was forced to write a generic wrapper class to access my nested Type.

(Part of) this functionality can therefore be implemented by adding a keypath to the decoder. The other part of the proposal aims to flatten Types that are nested in the JSON. That's not so important to me. In a declarative world one would do something like that with something like the new enum or adding support for dot notation in the existing CodingKeys enum rather than forcing the developer to write code for this, like now.

One problem I see is that if the Type is modified to de-nest it then if the Type is found in different places in different JSON then there would be a conflict. I often work with different APIs that return either a single instance of a Type or a list of instances of the Type where the list may be paged so that other properties exist at the top level. If the Type has to be changed to indicate that it's nested then things won't work in the not nested case.

First of all, thanks for the pitch. I am reviving this, because there has been some discussion around this offline.

I am generally in favor of adding more customization options, but they should be well justified. Other people on this thread have already mentioned, that in the particular example case it would be preferable to use a separate type for the metadata. I'm not saying that there aren't any cases where this proposal would add sufficient benefit, but it would be good to have such an example in the proposal.

Another concern I share with others, is that the name CodingKeyPath implies that it shares the typesafe and structural nature of KeyPath, which it doesn't.

Additionally I am not a fan of using strings to encode structural paths. In the protocol it is then being represented as [CodingKey]. So how are we getting from String to [CodingKey]? It seems that the String representation would only be used by the compiler, to generate the CodingKey enums that end up being used to represent the path. I don't think the compiler should interpret string contents in this way.

Thanks for the feedback!

Another concern I share with others, is that the name CodingKeyPath implies that it shares the typesafe and structural nature of KeyPath , which it doesn't.

Open to alternative names for this! Perhaps just "CodingPath" would work here?

Additionally I am not a fan of using strings to encode structural paths. In the protocol it is then being represented as [CodingKey] . So how are we getting from String to [CodingKey] ?

In the specific design outlined in this pitch, the [CodingKey] path is generated by splitting the String at each dot (.). There is a fair bit of prior art for this in other modal parsing frameworks, but I agree that representing the path using [String] would be preferable over using dot notation in a single String.

For example, I think a design like this would be quite ergonomic:

enum CodingPaths: [String], CodingPath {
  case id
  case title
  case reviewStartDate = ["metadata", "review_start_date"]
  case reviewEndDate = ["metadata", "review_end_date"]
}

This is not currently supported (I get a Raw value for enum case must be a literal error). I'm not familiar with why this limitation exists -- it seems like this could be possible in theory.

I should have been more clear: I don't think that the keys should be represented as strings, only to have them turned into CodingKeys by the compiler. The reason it is okay for the existing CodingKey to use String, is that it is a runtime only override of the field name. The compiler does not look at the string itself and certainly does not interpret it in any way to generate code. If we are going to add this feature, I don't want it to feel like a hack and if there is anything we have to add to make it nice, we need a strong justification for the overall feature first.

This is prima facie incorrect (you're changing the semantics of the CodingKey string raw value interpretation); and also may have real-life effects, because the pitch isn't just for JSON containers. For example, I might see the Vapor URL decoding setup breaking easily with this change, as URL parameters often have dots for namespacing (e.g. for OAuth-mandated fields).

I recommend perhaps a change in semantics when the coding key value is a different type, perhaps a path type of some sort rather than a dot in a string.

Encoder calls an array of coding keys a 'coding path': codingPath | Apple Developer Documentation

1 Like

This pitch doesn't propose any changes to the CodingKey type itself. It would definitely be a breaking change to tweak the semantics of the existing CodingKey type:

Totally agree -- this pitch proposes a new CodingKeyPath / CodingPath type separate from the existing CodingKey type :smile:

Hi @cal,

Thanks for making this pitch. It brings to light the fact we can improve the ergonomic of decoding deeply nested, large payloads that are encoded by an external party. We realize that such decoding scenarios are common on both server and client where you have to deal with data coming from external sources, commonly JSON from RESTful HTTP services, and would like to see it solved.

@Tony_Parker and myself discussed this further, and the ergonomic issue we see is that it can be fairly heavy handed to need and define the entire data structure hierarchy when the path to the data you want to extract is well known. For example, given the contrived JSON:

{ "a": { "b": { "c": { "d": { "e": { "f": { "g": { "h": { "i": { "foo": 42, "bar": true }}}}}}}}}}

It could be nice to only need and declare:

struct Payload: Codable  {
  var foo: Int
  var bar: Bool
}

And be able to decode it directly, given that we know the path to the payload is “a.b.c.d.e.f.g.h.i”. For completeness, the achieve this today you need to declare the entire hierarchy:

struct Payload: Codable {
  var a: A
  
  struct A: Codable {
    var b: B
  
    struct B: Codable {
      var c: C
    
      struct C: Codable {
        var d: D
      
        struct D: Codable {
          var e: E
        
          struct E: Codable {        
            var f: F
          
            struct F: Codable {
              var g: G
                      
              struct G: Codable {
                var h: H 
                         
                struct H: Codable {              
                  var i: I
                
                  struct I: Codable  {
                    var foo: Int
                    var bar: Bool
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

and access it via the type-safe accessors:

let payload = JSONDecoder().decode(Payload.self, from: data)
let i = payload.a.b.c.d.e.f.g.h.i

The main advantage of the current API is that it puts emphasis on type-safety. As such, we don’t feel string based key parsing (as proposed) is the way to go, given the following pitfalls with this (some of which have been pointed out on the thread):

  1. It introduces complexity into understanding Codable because there are now two kinds of keys (CodingKeys and CodingKeyPaths).
  2. If and when the decoding requirements expand and the user needs more values, it’s tempting to continue down the string-based path instead of using strong-types, or IOW it subtly encourages users to prefer short term convenience over type-safety.

That said, if we narrow the proposal to focus on the initial axis into the data, i.e. in such cases that the data starts at some well know deeply nested path, then we could probably solve this with a higher level API on the concrete Decoder itself. to use the example above, something like:

let payload = JSONDecoder().decode(Payload.self, from: data, at: "a.b.c.e.f.g.h.i")

Where that last argument is a CodingKeyPath type which is expressible by string literal. That reduces the scope of the change significantly and avoids the pitfalls mentions above. The last argument will do the string parsing before giving JSONDecoder an Array-like type, leaving the complexity outside the decoder itself.

We should further discuss where this higher level API belongs - the concrete Decoders or somewhere else, but we suspect this problem is more common in JSON compared to other Codable formats, so a JSON centric solution could potentially suffice.

3 Likes

This would be a great improvement!

Thanks @cal. Do you want to make the formal pitch and proposal for such change?

Are you thinking we would add this to Decoder (likely via a protocol extension, so it would be supported on all concrete Decoders?), or specifically just to JSONDecoder?

Last I heard, Foundation (and thus additions to JSONDecoder) are not included in the scope of Swift Evolution. Is that still the case?


I do think Decoder is a reasonable place for this, since it seems broadly useful and should compose nicely on top of existing methods available on the Decoder API. If that direction feels reasonable, I could work on a proposal and implementation for this :smiley:

@Tony_Parker do you have a preference / opinion?

cc @drexin @ktoso @weissi @fabianfett

That almost seems like it should be an extension on TopLevelDecoder only right? Which currently lives in Combine, and I think I can safely say that was something we have wanted to move lower for a while now. Perhaps this is the impetus to do so?

1 Like

This will be a good addition. Currently we use same approach as most API call returns data at different root keys based on resource type.

extension JSONDecoder {
    
    fileprivate static let keyPaths: CodingUserInfoKey = CodingUserInfoKey(rawValue: "keyPath")!
    
    open func decode<T>(_ type: T.Type, from data: Data, keyPath: String, separator: Character = ".") throws -> T where T : Decodable {
        self.userInfo[JSONDecoder.keyPaths] = keyPath.split(separator: separator).map({ String($0) })
        return try decode(ProxyModel<T>.self, from: data).object
    }
}
1 Like

granted this is a hack and far from nice by any metric, but still doable today!

let object = try JSONSerialization.jsonObject(with: data) as! NSObject
let i = object.value(forKeyPath: "a.b.c.d.e.f.g.h.i")!
let data2 = try JSONSerialization.data(withJSONObject: i)
let a = try JSONDecoder().decode(Payload.self, from: data2)
2 Likes