JSON Encoding / Decoding weird encoding of dictionary with enum values

Hi !
I use json format to store datas, but I am facing a weird behavior with my struct :

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted

enum Characteristic: String, Codable {
    case charac1
}

struct BuildingTemplate: Codable {
    let characteristics: [String: Double] // I have String in the key
}

struct BuildingTemplate2: Codable {
    let characteristics: [Characteristic: Double] // I have an enum value in the key
}

let toEncode1 = BuildingTemplate(
    characteristics: ["charac1": 5.0]
)

let toEncode2 = BuildingTemplate2(
    characteristics: [.charac1: 5.0]
)

do {
    let encoded = try encoder.encode(toEncode1)
    print(String(data: encoded, encoding: .utf8)!)
    let encoded2 = try encoder.encode(toEncode2)
    print(String(data: encoded2, encoding: .utf8)!)
} catch {
    print("error : \(error)")
}

the json in encoded2 is not containing a dictionary as expected. It is an Array with key and values alternating :

// encoded1 result :
{
  "characteristics" : {
    "charac1" : 5
  }
}
// encoded2 result :
{
  "characteristics" : [
    "charac1",
    5
  ]
}

is it normal, or a bug ?

This is expected.

Only dictionaries with Int or String key types get encoded into keyed containers (-> JSON dictionaries). Since other encodable types could encode to dictionaries/arrays, which can't be used as keys, dictionaries will encode as an array of alternating keys and values when the key type is not Int or String.

This is the relevant code:

1 Like

Ok ! Thank you for the response.
But since the enum have a rawValue corresponding to String (or Int),
Couldn't be great to use these as keys ? And for other enums, Keeping the current behavior ?

An enum with a raw type of String or Int can still manually implement encode(to:) and encode as anything it wants. It might be possible to add some static information about what a type encodes as to Encodable, but that would probably be a pretty invasive change to the current model of encoding, and hard to do non-source-breaking.

The workaround of mapping the dicts keys to strings in a custom implementation of BuildingTemplate2.encode(to:) should be pretty simple as well.

Okay ! I already added a workaround by implementing the method in BuildingTemplate2, but since it has more than one property, It is a lot of code just for this...

struct BuildingTemplate: Encodable {
    let name: String
    let description: String
    
    let maxLife: Double
    let characteristics: [Characteristic: Double]
    let requiredToBuild: [Resource: Int]
    
    let skillsIds: [SkillId]
}

extension BuildingTemplate: Template {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: BuildingTemplate.CodingKeys.self)
        
        self.name = try container.decode(String.self, forKey: .name)
        self.description = try container.decode(String.self, forKey: .description)
        self.maxLife = try container.decode(Double.self, forKey: .maxLife)
        let characteristicsDictionary = try container.decode([String: Double].self, forKey: .characteristics)
        self.characteristics = try characteristicsDictionary.map { tuple in
            guard let key = Characteristic(rawValue: tuple.key) else {
                throw DecodingError.valueNotFound(
                    Characteristic.self,
                    DecodingError.Context(codingPath: [BuildingTemplate.CodingKeys.characteristics], debugDescription: "unable to build an enum value with value provided : \(tuple.key)")
                )
            }
            return (key, tuple.value)
        }
        let requiredToBuildDictionary = try container.decode([String: Int].self, forKey: .requiredToBuild)
        self.requiredToBuild = try requiredToBuildDictionary.map { tuple in
            guard let key = Resource(rawValue: tuple.key) else {
                throw DecodingError.valueNotFound(
                    Resource.self,
                    DecodingError.Context(codingPath: [BuildingTemplate.CodingKeys.requiredToBuild], debugDescription: "unable to build an enum value with value provided : \(tuple.key)")
                )
            }
            return (key, tuple.value)
        }
        self.skillsIds = try container.decode([SkillId].self, forKey: .skillsIds)
    }
}

This behavior is so non-obvious and illogical that I'd consider it a bug. Consider the following code:

let json = "{\"key\": \"value\"}"
enum Key: String, Codable {
    case key
}
let jsonData = Data(json.utf8)

do {
    let decoded = try JSONDecoder().decode([Key: String].self, from: jsonData)
    print(decoded)
} catch {
    print(error)
}

Currently it prints an error: typeMismatch(Swift.Array<Any>, Swift.DecodingError.Context(codingPath: [], debugDescription: "Expected to decode Array<Any> but found a dictionary instead.", underlyingError: nil)). Nobody who isn't intimately familiar with JSONDecoder's handling of this scenario would understand what's going on here. In fact, this behavior flies in the face of any previous experience users would have with the automatic decoding of String or Int backed enums and the behavior of those enums in Swift in general, where you can easily pass between [Key: String and [String: String]. The Array behavior is something no one would expect and no one would want.

@itaiferber Is this an area where we can change?

3 Likes

I agree that this is more a bug than a feature; this was an oversight in the implementation as far as I’m concerned. Something that is RawRepresentable as a String or Int encodes as a String or Int by default everywhere else, and I don’t think this should be different.

The one thing we need to figure out is how bad the regression would be if we changed behavior, as we would introduce incompatibility in the other direction.

Do you or @zarghol mind filing a bug about this, please? If we don’t break too much, we should do this sooner rather than later before folks rely on this behavior (if that’s really possible).

5 Likes

I created the Issue : [SR-7788] Enum with String or Int RawRepresentable not encoded / decoded properly · Issue #3690 · apple/swift-corelibs-foundation · GitHub

2 Likes

Thanks!

I encountered this issue today and it's quite annoying as there are no easy workarounds. Would it be possible to fix this while staying backwards compatible by introducing a dictionaryEncodingStrategy that defaults to the current behavior but have an additional behavior that encodes RawRepresentable types using their raw value?

4 Likes

I also encountered this issue today. Count not find this topic, so I created a new one (Decoding a dictionary with a custom type (not String) as key), and the answers there brought me here.

I agree with @Jon_Shier, I would consider this a bug for the same reasons he mentioned.

2 Likes

This is now supported in 5.6 through SE-0320 for anyone who ends up here through search.

5 Likes