SE-0295: Codable synthesis for enums with associated values

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