Swift 4 Decodable - Dictionary with enum as key

In Swift 5.6 (Xcode 13.3) SE-0320 CodingKeyRepresentable has been implemented which solves the issue.

It adds implicit support for dictionaries keyed by enums conforming to RawRepresentable with Int and String raw values.


The problem is that Dictionary's Codable conformance can currently only properly handle String and Int keys. For a dictionary with any other Key type (where that Key is Encodable/Decodable), it is encoded and decoded with an unkeyed container (JSON array) with alternating key values.

Therefore when attempting to decode the JSON:

{"dictionary": {"enumValue": "someString"}}

into AStruct, the value for the "dictionary" key is expected to be an array.

So,

let jsonDict = ["dictionary": ["enumValue", "someString"]]

would work, yielding the JSON:

{"dictionary": ["enumValue", "someString"]}

which would then be decoded into:

AStruct(dictionary: [AnEnum.enumValue: "someString"])

However, really I think that Dictionary's Codable conformance should be able to properly deal with any CodingKey conforming type as its Key (which AnEnum can be) – as it can just encode and decode into a keyed container with that key (feel free to file a bug requesting for this).

Until implemented (if at all), we could always build a wrapper type to do this:

struct CodableDictionary<Key : Hashable, Value : Codable> : Codable where Key : CodingKey {

    let decoded: [Key: Value]

    init(_ decoded: [Key: Value]) {
        self.decoded = decoded
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: Key.self)

        decoded = Dictionary(uniqueKeysWithValues:
            try container.allKeys.lazy.map {
                (key: $0, value: try container.decode(Value.self, forKey: $0))
            }
        )
    }

    func encode(to encoder: Encoder) throws {

        var container = encoder.container(keyedBy: Key.self)

        for (key, value) in decoded {
            try container.encode(value, forKey: key)
        }
    }
}

and then implement like so:

enum AnEnum : String, CodingKey {
    case enumValue
}

struct AStruct: Codable {

    let dictionary: [AnEnum: String]

    private enum CodingKeys : CodingKey {
        case dictionary
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        dictionary = try container.decode(CodableDictionary.self, forKey: .dictionary).decoded
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(CodableDictionary(dictionary), forKey: .dictionary)
    }
}

(or just have the dictionary property of type CodableDictionary<AnEnum, String> and use the auto-generated Codable conformance – then just speak in terms of dictionary.decoded)

Now we can decode the nested JSON object as expected:

let data = """
{"dictionary": {"enumValue": "someString"}}
""".data(using: .utf8)!

let decoder = JSONDecoder()
do {
    let result = try decoder.decode(AStruct.self, from: data)
    print(result)
} catch {
    print(error)
}

// AStruct(dictionary: [AnEnum.enumValue: "someString"])

Although that all being said, it could be argued that all you're achieving with a dictionary with an enum as a key is just a struct with optional properties (and if you expect a given value to always be there; make it non-optional).

Therefore you may just want your model to look like:

struct BStruct : Codable {
    var enumValue: String?
}

struct AStruct: Codable {

    private enum CodingKeys : String, CodingKey {
        case bStruct = "dictionary"
    }

    let bStruct: BStruct
}

Which would work just fine with your current JSON:

let data = """
{"dictionary": {"enumValue": "someString"}}
""".data(using: .utf8)!

let decoder = JSONDecoder()
do {
    let result = try decoder.decode(AStruct.self, from: data)
    print(result)
} catch {
    print(error)
}

// AStruct(bStruct: BStruct(enumValue: Optional("someString")))

In order to solve your problem, you can use one of the two following Playground code snippets.


#1. Using Decodable's init(from:) initializer

import Foundation

enum AnEnum: String, Codable {
    case enumValue
}

struct AStruct {
    enum CodingKeys: String, CodingKey {
        case dictionary
    }
    enum EnumKeys: String, CodingKey {
        case enumValue
    }

    let dictionary: [AnEnum: String]
}

extension AStruct: Decodable {

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let dictContainer = try container.nestedContainer(keyedBy: EnumKeys.self, forKey: .dictionary)

        var dictionary = [AnEnum: String]()
        for enumKey in dictContainer.allKeys {
            guard let anEnum = AnEnum(rawValue: enumKey.rawValue) else {
                let context = DecodingError.Context(codingPath: [], debugDescription: "Could not parse json key to an AnEnum object")
                throw DecodingError.dataCorrupted(context)
            }
            let value = try dictContainer.decode(String.self, forKey: enumKey)
            dictionary[anEnum] = value
        }
        self.dictionary = dictionary
    }

}

Usage:

let jsonString = """
{
  "dictionary" : {
    "enumValue" : "someString"
  }
}
"""

let data = jsonString.data(using: String.Encoding.utf8)!
let decoder = JSONDecoder()
let aStruct = try! decoder.decode(AStruct.self, from: data)
dump(aStruct)

/*
 prints:
 ▿ __lldb_expr_148.AStruct
   ▿ dictionary: 1 key/value pair
     ▿ (2 elements)
       - key: __lldb_expr_148.AnEnum.enumValue
       - value: "someString"
 */

#2. Using KeyedDecodingContainerProtocol's decode(_:forKey:) method

import Foundation

public enum AnEnum: String, Codable {
    case enumValue
}

struct AStruct: Decodable {
    enum CodingKeys: String, CodingKey {
        case dictionary
    }

    let dictionary: [AnEnum: String]
}

public extension KeyedDecodingContainer  {

    public func decode(_ type: [AnEnum: String].Type, forKey key: Key) throws -> [AnEnum: String] {
        let stringDictionary = try self.decode([String: String].self, forKey: key)
        var dictionary = [AnEnum: String]()

        for (key, value) in stringDictionary {
            guard let anEnum = AnEnum(rawValue: key) else {
                let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Could not parse json key to an AnEnum object")
                throw DecodingError.dataCorrupted(context)
            }
            dictionary[anEnum] = value
        }

        return dictionary
    }

}

Usage:

let jsonString = """
{
  "dictionary" : {
    "enumValue" : "someString"
  }
}
"""

let data = jsonString.data(using: String.Encoding.utf8)!
let decoder = JSONDecoder()
let aStruct = try! decoder.decode(AStruct.self, from: data)
dump(aStruct)

/*
 prints:
 ▿ __lldb_expr_148.AStruct
   ▿ dictionary: 1 key/value pair
     ▿ (2 elements)
       - key: __lldb_expr_148.AnEnum.enumValue
       - value: "someString"
 */

Tags:

Swift

Codable