SE-0295: Codable synthesis for enums with associated values

I'm very keen to see automatic Codable for enums.

The proposal should address how internal names would be treated, for example:

enum Example {
    case myValue(_ value: Int)
}

If the internal value names were used for coding purposes, this could allow keyed coding while keeping the code-facing API unlabelled. It also makes sense to me that the internal name would be the ‘true’ property name, while the external name would just affect a case's ‘initialiser’.

I agree this is a concern, but I also want to point out that there should be some way to have automatic synthesis, even if the user has to specify a particular coding method.

From my limited experience writing Codable enums (though I expect others will feel the same), I always have a specific strategy in mind applicable to all current and future cases, in which case manual conformance serves as tedious copy & paste boilerplate at best, and a potential source of obscure decoding errors at worst.

If we're going to use unkeyed container anyway, could we use unkeyed container all the way?

enum A {
  case a(a: String, b: Int)
}

A.a(a: "SomeString", b: 4)
// JSON
{
  "a": [ "someString", 4 ]
}

Even on normal usage, you won't normally see the label when switching:

switch A.a(...) {
case let a(a, b): // there's no need for label.
}

Even when we have tuple codable, given that labels are not type-significant, it may also get the same treatment.

It'd be nice if the boilerplate is relatively easy to implement. If there's some necessary tweak later on, someone may need to implement that boilerplate just to evolve the coded structure.

1 Like

I've just implemented yet another Codable enum in a project of mine (single-unlabled-associated-value all the way :slightly_smiling_face:) and I was wondering what the policy was for handling the presence of multiple case-associated keys. I see two options:

  1. Any number of case-associated keys may be present. Only the payload the first key present in case-declaration order is decoded.
  2. Exactly 1 case-associated key must be present, all other situations throw an error.

(1) accepts more representations as valid, but may incur an unacceptable performance penalty (since you would be serially checking the presence of each key). As such, I'm a bigger fan of (2), which would still allow for keys not associated with cases to be present in the container (metadata like _error comes to mind). Either way, I think we should call this out explicitly in the proposal.

You would have to specify it somehow. Perhaps using a static defaultCase property? I’ll see if I can sketch out how it could work. (Edit: See counter proposal.)

You mean something like:

enum Color: Codable {
  case sRGB(r: Double, g: Double, b: Double)
  case p3(r: Double, g: Double, b: Double)
}

where we’ve added P3 support in a newer version, and don’t want older versions to read P3 colors as sRGB colors? Indeed, that’s a weakness compared to the proposed format. If that’s important, the schema author would have to implement encoding/decoding manually to make sure that the coding keys for the p3 case don’t clash with those of the sRGB case. This example seems a bit contrived though — there’s not much danger in interpreting a P3 color as an sRGB color. Do you have an example in mind where this would be catastrophic?

Note that you can run into the same situation when encoding structs already. These two structs encode the same in a format like JSON that doesn’t differentiate between integers and floats:

struct ColorA: Codable {
  var r, g, b: UInt8
}

struct ColorB: Codable {
  var r, g, b: Double
}

So if color components in ColorA are 0–255, and color components in ColorB are 0.0–1.0, ColorAs can be decoded as ColorBs, but will have incorrect values. Of course, you’d never evolve your schema this way, precisely because of this problem.

In general, I don’t think Codable can ensure correctness when evolving schemas. It’s just a toolkit. The responsibility for correctness has to rest on the schema author who is using Codable to describe their schema. The reason I think encoding the case name as its own key is a better default is that it gives schema authors one more tool in the toolkit.


Another note: encoding the case name as its own key also solves the problem of what to do with cases without associated values. The two cases in:

enum Foo: Codable {
  case bar
  case baz(value: Double)
}

Would be encoded without any special handling, and without empty containers, as:

{
  "$case": "bar"
}

{
  "$case": "baz",
  "value": 1
}

The distinction between conforming an enum without associated values to String, Codable or to just Codable is then: do you want to be able to add associated values later? That’s another decision that Codable can’t make for you. Only as the schema author can you know that.

1 Like

Alright, here’s the alternative design I’ve alluded to, sketched out in full so we have something to compare against. I’ve copied the disposition and examples from the proposal to make comparison easier.

This design matches how I’ve always encoded enums with associated values and how I’ve often seen union-like structures encoded in web APIs. (Edit: For example, this is how unions are encoded when using the OpenAPI specification.) Personally, I think this is a much more natural encoding, and I believe it gives schema authors more flexibility in how to evolve their schemas over time while maintaining backward and forward compatibility.


Counter proposal

Structure of encoded enums

The following enum with associated values

enum Command: Codable {
  case load(key: String)
  case store(key: String, value: Int)
}

is encoded as

{
  "$case": "load",
  "key": "MyKey"
}

{
  "$case": "store",
  "key": "MyKey",
  "value": 42
}

The container contains a "$case" key with the base name of the enum case as its value. The associated values are then encoded into the same container the same way properties are encoded for structs and classes. Since only compiler generated identifiers in Swift can start with $, the "$case" key cannot clash with any of the associated values.

Associated values can also be unlabeled, in which case they are encoded with the keys "$0", "$1" etc named after their position. Since only compiler generated identifiers in Swift can start with $, these keys cannot clash with any of the labeled associated values.

enum Command: Codable {
  case load(String)
  case store(String, value: Int)
}

is encoded as

{
  "$case": "load",
  "$0": "MyKey"
}

{
  "$case": "store",
  "$0": "MyKey",
  "value": 42
}

A case without associated values encodes the same as a case with associated values.

enum Command: Codable {
  case dumpToDisk
}

is encoded as

{
  "$case": "dumpToDisk"
}

This makes it possible to add associated values to the enum later, while maintaining backward and forward compatibility for the existing cases.

User customization

Customizing keys

Users can customize the keys used when encoding the case and associated values by defining their own CodingKeys enum instead of having the compiler synthesize one. To customize the key used for encoding the case, the special case named "`case`" is used. To customize the keys used for associated values, cases corresponsing to each associated value label is used.

enum Command: Codable {
	enum CodingKeys: String, CodingKey {
	  case `case` = "type"
	  case key = "name"
	  case value
	}

  case load(key: String)
  case store(key: String, value: Int)
}

is encoded as

{
  "type": "load",
  "name": "MyKey"
}

{
  "type": "store",
  "name": "MyKey",
  "value": 42
}

Leaving out the case for an associated value label means that associated values with that label are left out when encoding. Leaving out the case named "`case`" is an error, as that would make the entire enum undecodable.

Customizing the case key is useful when interoperating with schemas that use another key to encode a discriminator, e.g. schemas that use the key "_class" to differentiate between subclasses. This lets users easily decode such containers as enums in Swift, even if they weren’t represented as enums originally.

Customizing cases

Users can customize the values used when encoding cases by defining a CodingCases enum that maps each case to the value it should be encoded as.

enum Command: Codable {
  enum CodingCases: Int, CodingCase {
    case load = 0
    case store = 1
  }

  case load(key: String)
  case store(key: String, value: Int)
}

is encoded as

{
  "$case": 0,
  "key": "MyKey"
}

{
  "$case": 1,
  "key": "MyKey",
  "value": 42
}

This is useful when interoperating with schemas that use integer case values, or case names that don’t match the case names of the enum.

Specifying a default case

Users can specify a default case used during decoding if the case key is missing. This is done by defining a static defaultCase property on the CodingCases enum:

enum Command: Codable {
  enum CodingCases: String, CodingCase {
    case load
    case store
    static var defaultCase: CodingCases? { .load }
  }

  case load(key: String)
  case store(key: String, value: Int)
}

This is useful when evolving a schema type from a product type (a struct) to a sum type (an enum) where the previous type is now just one of many cases. Specifying a default case lets you maintain backward compatibility with data encoded using the old schema. For example, data encoded with

struct Image: Codable {
  var resource: ImageResource
}

can be decoded with

enum Media: Codable {
  enum CodingCases: String, CodingCase {
    case image
    case video
    static var defaultCase: CodingCases? { .image }
  }

  case image(resource: ImageResource)
  case video(resource: VideoResource, startTime: Double)
}

CodingCase

The CodingCase protocol used for case values is a refinement of the existing CodingKey protocol with the added requirement static var defaultCase: Self? { get }. It has a default implementation that returns nil, meaning that there is no default case.

/// A type that can be used as a case value for encoding and decoding.
protocol CodingCase: CodingKey {
  /// The default case used during decoding when the case key is missing.
  static var defaultCase: Self? { get }
}

extension CodingCase {
  static var defaultCase: Self? { nil }
}

Aside from this addition, CodingCase has the exact same synthesis behaviour as CodingKey.

Synthesis limitations

Since the base name of each case is used as a discriminator, synthesis is only possible for enums with unique base names.

Synthesis proof of concept

Here is a proof of concept of what the synthesized code could look like. You can paste this into a Playground and play around with it.

Code
import Foundation

// MARK: - Protocol

protocol CodingCase: CodingKey {
    static var defaultCase: Self? { get }
}

extension CodingCase {
    static var defaultCase: Self? { nil }
}


// MARK: - Types

enum Command: Codable {
    enum CodingKeys: String, CodingKey {
        case `case` = "type"
        case key = "name"
        case value
    }

    enum CodingCases: Int, CodingCase {
        case load = 0
        case store = 1
        static var defaultCase: CodingCases? { .load }
    }

    case load(key: String)
    case store(key: String, value: Int)

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let `case` = try container.decode(CodingCases.self, forKey: .case)
        switch `case` {
        case .load:
            self = .load(
                key: try container.decode(String.self, forKey: .key)
            )
        case .store:
            self = .store(
                key: try container.decode(String.self, forKey: .key),
                value: try container.decode(Int.self, forKey: .value)
            )
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        switch self {
        case .load(let key):
            try container.encode(CodingCases.load, forKey: CodingKeys.case)
            try container.encode(key, forKey: CodingKeys.key)
        case .store(let key, let value):
            try container.encode(CodingCases.store, forKey: CodingKeys.case)
            try container.encode(key, forKey: CodingKeys.key)
            try container.encode(value, forKey: CodingKeys.value)
        }
    }
}

struct Load: Codable {
    enum CodingKeys: String, CodingKey {
        case key = "name"
    }

    var key: String
}

private extension KeyedDecodingContainer {
    func decode<C>(_ type: C.Type, forKey key: K) throws -> C where C: CodingCase {
        if !contains(key), let defaultCase = C.defaultCase {
            return defaultCase
        }
        if let intCase = (try? decode(Int.self, forKey: key)).flatMap({ C(intValue: $0) }) {
            return intCase
        }
        if let stringCase = (try? decode(String.self, forKey: key)).flatMap({ C(stringValue: $0) }) {
            return stringCase
        }
        throw DecodingError.dataCorruptedError(
            forKey: key,
            in: self,
            debugDescription: "Can’t decode coding case with key \(key)"
        )
    }
}

private extension KeyedEncodingContainer {
    mutating func encode<C>(_ codingCase: C, forKey key: K) throws where C: CodingCase {
        if let intValue = codingCase.intValue {
            try encode(intValue, forKey: key)
        } else {
            try encode(codingCase.stringValue, forKey: key)
        }
    }
}


// MARK: - Test

let command1 = Command.load(key: "MyKey")
let encodedCommand1 = String(data: try JSONEncoder().encode(command1), encoding: .utf8)
let decodedCommand1 = try JSONDecoder().decode(Command.self, from: encodedCommand1!.data(using: .utf8)!)

let command2 = Command.store(key: "MyKey", value: 42)
let encodedCommand2 = String(data: try JSONEncoder().encode(command2), encoding: .utf8)
let decodedCommand2 = try JSONDecoder().decode(Command.self, from: encodedCommand2!.data(using: .utf8)!)

let load = Load(key: "MyKey")
let encodedLoad = String(data: try JSONEncoder().encode(load), encoding: .utf8)
let decodedLoad = try JSONDecoder().decode(Load.self, from: encodedLoad!.data(using: .utf8)!)
let decodedLoadAsCommand = try JSONDecoder().decode(Command.self, from: encodedLoad!.data(using: .utf8)!)
5 Likes

Properties of a struct are not encoded into the same container as the name of the property.

I see the following two as dualities:

struct Both<A, B> {
  var a: A
  var b: B
}

enum Either<A, B> {
  case a(A)
  case b(B)
}

With the format that is evolving in the discussion in this topic, encoding (asuming it was Encodable) a value of Both: Both<Int, Int>(a: 1, b: 2) gives something like:

{
  "a": 1,
  "b": 2
}

Encoding a value of it's dual Either: Either<Int, Int>.a(1) gives:

{
  "a": 1
}

So I really don't see any extra levels of nesting - a case name is just the duality of a variable name.

A weak argument towards my perception of this is that this is also how the Mirror functionality describes an enum value (i.e. with the case as it's name and the value being an optional Any containing the value (which may be a tuple).

I'm unconvinced that this is really true. Sending JSON with newly-added associated values to an older program that doesn't expect them will at least throw away the associated values. The idea that the old case is still somehow a meaningful variant of the new one (as opposed to being syntactically compatible) seems dubious in general.

Conversely, sending JSON without associated values to a newer program that does expect them will fail because decoding cannot provide the missing values.

There are ways of dealing with these issues in any given scenario, but I'm not sure there's a safe, general solution that we should build into Codable.

On the struct vs. enum representation, I agree with your insight that an enum case is a lot like a (nested) struct, so that one representation can actually serve both uses — more or less. However, your solution isn't really backwards compatible, because a newer program could send a newer case (with different associated values from the original case) to the old program, which wouldn't be able to decode it.

Your goal of providing compatibility is admirable, but I don't see a convincing, feasible solution presented yet. IMO, compatibility does have to be considered for each individual scenario. A general solution is likely to be a footgun for the unwary (but … feel free to prove me wrong :slightly_smiling_face:) .

The "safe" approach, it seems to me, is for newer programs to accept both the old type (e.g. struct) and the new type (e.g. case with associated values), whichever is present, and make a proper choice between them when both are present. Such programs would, of course, encode both old and new types into the JSON, if the loss of the data carried in the new type was acceptable in the particular scenario.

1 Like

I would, as you can probably guess, disagree. A case name is not the dual of a property name. It’s a discriminant. In memory, property names aren’t repeated in each struct or enum instance, but the enum’s discriminant is (because otherwise there would be no way to tell cases apart). To put it in Codable terms: an enum’s discriminant is a value, not a key.

Your Either type that encodes a and b as properties is, IMO, much closer to something that can already be expressed with Codable:

struct Either<A,B>: Codable where A: Codable, B: Codable {
  var a: A?
  var b: B?
}

I think this is closer because the encoding doesn’t actually represent the fact that only a or b can be defined, but not both.

This might be one of the reasons I’m so uneasy with the proposed encoding: the defining feature of an enum – that each value has one, and only one, case – isn’t actually represented in the encoding!

Which makes me wonder, @drexin, how does the proposed decoding handle the situation where multiple case keys are present? For example:

{
  "load": {
    "key": "MyKey"
  },
  "store": {
    "key": "MyKey",
    "value": 42
  }
}

(Apologies if this is already answered somewhere. I looked through the thread and proposal, but I couldn’t find it.)

Using a structure with optional values is exactly how I would represent an enum with associated values in a language that does not support that directly. For instance if I was sharing the model with some typescript code.
That the two concepts are so similar (struct with optional values and enum) is a strength in my opinion.

As I see it, the encoding is just data and it doesn’t represent anything without a corresponding decoder. Having a key named “$case” with a value of “load” does not represent that it’s an enum value either - it’s just data.

Although my intuition aligns with that of @Morten_Bek_Ditlevsen, I have to agree with @Jumhyn's comment above that if there is no broad consensus here as to the "obviously correct" implementation, this argues strongly for not having protocol requirement synthesis at all. It does worry me that even at the final step (i.e., review) of this proposal, we have not converged on an answer 50 messages in.

8 Likes

The duality is weakly founded. Values of Both, has both (duh!) an a and b, and their existence in the coded structure isn't part of their value. However, a value of Either has a case. The case is a property of the instance, not of the type. The case is thus a value.

1 Like

What is your evaluation of the proposal?

-1

I think the proposal is premature. This review thread reads like a pitch thread.

Is the problem being addressed significant enough to warrant a change to Swift?

The problem is significant enough, but the proposed solution doesn't seem right.

It hasn't been discussed in this thread yet, but having synthesised Codable requirement can greatly ease the encoding/decoding arbitrarily nested data with indirect/recursive enums.

Does this proposal fit well with the feel and direction of Swift?

No.

Swift values correctness in default behaviour. It's not convincing that the proposed solution is the correct serialisation/deserialisation strategy for enums.

The proposed solution is not general enough, so it applies to only some enums. It's also looks unlikely the feature, if accepted, can be easily expanded to cover more kind of enums.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I haven't used anything with a similar feature.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I didn't participat the pitch tread(s). I read the proposal in full, read all posts up-thread, and posted a few suggestions.

4 Likes

It would pick the first key, i.e. container.keys.first?.

But this isn't necessarily the first key in the file, right? If a decoder stores key value pairs in a dictionary the order is not even deterministic because hashes are different each time the program runs.

Correct, but in general there should only be one key. The alternative would be to throw an error if multiple keys are present.

The deterministic alternative would be to check for each key in the order cases are defined the enum, and picking the first one with a non-nil value... which I agree could be problematic for performance if the enum has a lot of cases.

Since the encoder will never produce an object with multiple keys at that level, I think it's okay to not handle it in this way.

What about malicious payloads? It seems weird to assume that no-one else has had a chance to touch data that has been stored to disk or sent over the wire. :thinking:

3 Likes
What is your evaluation of the proposal?

Looks like a nice feature, but I find the idea of a dictionary that always contains a single key value pair a bit strange. In particular, the decoding behavior in the presence of a dictionary contains more than one key for the discriminant should be documented. It's also important that it should be deterministic and not depend on hashing table order. One easy way to make it deterministic would be to throw a decoding error if the keyed container contains a second value.

Why is determinism important? Take two systems: one does some action based on the input while the other filters the input to block unauthorized actions. If the two systems perform their own decoding of the input, it's easy for an attacker to craft something that has one chance out of two of passing the filter and one chance out of two of executing the malicious action (1/4 for the two systems combined).

Apart from that, the value is an array, or a dictionary if all values have a label. I think it'd be better to use a dictionary all the time, perhaps with numeric keys for missing labels: this makes the structure more predictable.

I think @hisekaldma's counterproposal is better at providing a predictable serialization of the data. Its decoding is also more deterministic/unambiguous as there are no input that could mean two cases. It's particularly nice that it makes it easy to switch the implementation between an enum a struct without breaking the serialization.

Is the problem being addressed significant enough to warrant a change to Swift?

This would be a useful improvement. Currently it's a pain to store enums with associated values.

Does this proposal fit well with the feel and direction of Swift?

I think it does. Making a data structure codable should be easy. I just doubt this is the correct serialization format.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

N/A

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A quick reading.

5 Likes