Swift 4 Decodable - Dictionary with enum as key
Asked Answered
C

6

43

My data structure has an enum as a key, I would expect the below to decode automatically. Is this a bug or some configuration issue?

import Foundation

enum AnEnum: String, Codable {
  case enumValue
}

struct AStruct: Codable {
  let dictionary: [AnEnum: String]
}

let jsonDict = ["dictionary": ["enumValue": "someString"]]
let data = try! JSONSerialization.data(withJSONObject: jsonDict,     options: .prettyPrinted)
let decoder = JSONDecoder()
do {
  try decoder.decode(AStruct.self, from: data)
} catch {
  print(error)
}

The error I get is this, seems to confuse the dict with an array.

typeMismatch(Swift.Array, Swift.DecodingError.Context(codingPath: [Optional(__lldb_expr_85.AStruct.(CodingKeys in _0E2FD0A9B523101D0DCD67578F72D1DD).dictionary)], debugDescription: "Expected to decode Array but found a dictionary instead."))

Coahuila answered 23/6, 2017 at 15:34 Comment(2)
There's now a discussion on the Swift forum about this here: forums.swift.org/t/… And a bug for this issue here: bugs.swift.org/browse/SR-7788Holbrooke
I tried to do the same thing but I got the following error "The data couldn’t be read because it isn’t in the correct format."Cowcatcher
F
54

From Swift 5.6

Swift Proposal [SE-0320] allows non String/Int (e.g. a enum) as key of a dictionary.

Conform your enum to the CodingKeyRepresentable protocol.

e.g.

enum AnEnum: String, Codable, CodingKeyRepresentable {
  case enumValue
}

Before Swift 5.6 (original answer)

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")))
Flori answered 23/6, 2017 at 16:30 Comment(1)
I don't control the API so can't restructure the data at that point. Implementing as CodableDictionary seems like my best option as the model has a bunch of other fields and (unless I'm missing something?) there's no way to benefit from the auto generated code once I override init(from decoder:). A struct with enum keys is similar but the api specifies ordering of those values by key in a separate array of those keys(not my api!). I'll be sure to file a bug report.Coahuila
M
6

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.

Masters answered 23/6, 2017 at 15:58 Comment(1)
Excellent answer @Masters - this is the correct answer in 2022.Bimah
T
6

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"
 */
Teeny answered 13/7, 2017 at 10:21 Comment(0)
S
4

Swift Proposal [SE-0320] now allow us to use use a non String/Int (e.g. a enum) as key of a dictionary.

To enable that, the type just needs to conform to CodingKeyRepresentable protocol.

See an example below:

enum Device: String, Codable, CodingKeyRepresentable {
   case iphone
   case mac
   case watch
}

var deviceCollection = [Device: [String]]()

// encoding and decoding will work exactly the same as String/Int
let data = try JSONEncoder().encode(deviceCollection)

let content = try JSONDecoder().decode(data, from: [Device: [String]].self)

Schuh answered 28/6, 2023 at 18:31 Comment(1)
This was covered in another answer 6 years ago.Ashelyashen
O
1

Following from Imanou's answer, and going super generic. This will convert any RawRepresentable enum keyed dictionary. No further code required in the Decodable items.

public extension KeyedDecodingContainer
{
    func decode<K, V, R>(_ type: [K:V].Type, forKey key: Key) throws -> [K:V]
        where K: RawRepresentable, K: Decodable, K.RawValue == R,
              V: Decodable,
              R: Decodable, R: Hashable
    {
        let rawDictionary = try self.decode([R: V].self, forKey: key)
        var dictionary = [K: V]()

        for (key, value) in rawDictionary {
            guard let enumKey = K(rawValue: key) else {
                throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: codingPath,
                     debugDescription: "Could not parse json key \(key) to a \(K.self) enum"))
            }
            
            dictionary[enumKey] = value
        }

        return dictionary
    }
}
Ossie answered 11/11, 2020 at 11:10 Comment(0)
S
0

Following Giles's answer, here is the same idea, but in the other direction, for encoding

public extension KeyedEncodingContainer {
    mutating func encode<K, V, R>(_ value: [K: V], forKey key: Key) throws
        where K: RawRepresentable, K: Encodable, K.RawValue == R,
              V: Encodable,
              R: Encodable, R: Hashable {
        try self.encode(
            Dictionary(uniqueKeysWithValues: value.map { ($0.key.rawValue, $0.value) }),
            forKey: key
        )
    }

    mutating func encodeIfPresent<K, V, R>(_ value: [K: V]?, forKey key: Key) throws
        where K: RawRepresentable, K: Encodable, K.RawValue == R,
              V: Encodable,
              R: Encodable, R: Hashable {
        if let value = value {
            try self.encode(value, forKey: key)
        }
    }
}
Snakebird answered 12/7, 2022 at 12:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.