Decoding a dictionary with a custom type (not String) as key

Hello everybody :slight_smile:

The following code builds and runs, and prints ["key": "value"], as expected:

typealias Dict = [String: String]

do {
    let decoder = JSONDecoder()
    let string = Data("""
    {
        "key": "value"
    }
    """.utf8)

    let decoded = try decoder.decode(Dict.self, from: string)
    print(decoded)
}

When I change the type of the key as follows:

struct String2: Decodable, Hashable {
    let value: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        value = try container.decode(String.self)
    }
}

typealias Dict = [String2: String]

do {
    let decoder = JSONDecoder()
    let string = Data("""
    {
        "key": "value"
    }
    """.utf8)

    let decoded = try decoder.decode(Dict.self, from: string)
    print(decoded)
}

I would expect it to behave similarly. The reality is that the code builds, but when running shows the following error:

Fatal error: Error raised at top level: Swift.DecodingError.typeMismatch(Swift.Array<Any>, Swift.DecodingError.Context(codingPath: [], debugDescription: "Expected to decode Array<Any> but found a dictionary instead.", underlyingError: nil)): file /AppleInternal/BuildRoot/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.8.25.8/swift/stdlib/public/core/ErrorType.swift, line 200

Such code does not even reach the content of the init(from decoder: Decoder) throws function inside the String2 type.

Am I missing something? Any idea why this happens?

Thanks,
Andres

Dictionary has the following coding strategy:

  1. If Key is String or Int and nothing else, it uses keyed container which, for JSON, corresponds to

    { "key1": <value1>, "key2": <value2>, ... }
    
  2. Otherwise, it uses unkeyed container and encode it like this:

    [ <key1>, <value1>, <key2>, <value2>, ... ]
    

The error is raise because to data is an object, but you're expecting an array (because of rule 2), which it detects long before init(from:).

1 Like

I see. Thanks for the answer.

Is it unreasonable to expect the second piece of code to work as I mentioned?

It was discussed before a few times, here for one.

1 Like

See also the discussion in Bug or PEBKAC? (and my response in the thread for some background on why this is the case).

1 Like

Should be this one (I tried to link there, but couldn't find the post :stuck_out_tongue:).

(Thanks, fixed! Copy-pasta... :upside_down_face:)

1 Like

Ran into this same issue. I wanted a Dictionary instead of a struct because order doesn't matter. Thus I went with this solution which is a bit clunky but does the job.

enum ToolbarMark: String {
    case bold
    case italic
    case strikethrough
    case ul
    case ol
    case h1
    case h2
    case h3
    case p
    case action_item
}

// ...
if let legitString = messageBody["toolbarState"]?.data(using: .utf8) {
    do {
        // Swift does not support parsing of JSON into dictionaries where keys
        // are not strings or integers. Thus we first parse as strings then cast
        // appropriately. We are not using structs for toolbar state because
        // order doesn't matter and that's not something we want to enforce yet
        
        let jsonObject = try JSONSerialization.jsonObject(with: legitString) as? [String: Bool]
        
        var nextToolbarState: [ToolbarMark:Bool] = [:]
        jsonObject?.forEach({ (key: String, value: Bool) in
            if let mark = ToolbarMark.init(rawValue: key) {
                nextToolbarState[mark] = value
            }
        })
        
        textEditor.setToolbarState(nextToolbarState)
    } catch (let err) {
        print(err)
    }
}

I come from a JS background so I may be missing much of the context of why I might be wrong but thought I'd share either way.

As of SE-0320: Allow coding of non String / Int keyed Dictionary into a KeyedContainer and Swift 5.6, you should now be able to conform your ToolbarMark enum to CodingKeyRepresentable (benefitting from the default implementation that String- and Int-backed RawRepresentable types get) and be able to decode [ToolbarMark: Bool] directly:

enum ToolbarMark: String, Decodable, CodingKeyRepresentable {
    case bold
    case italic
    case strikethrough
    case ul
    case ol
    case h1
    case h2
    case h3
    case p
    case action_item
}

let data = """
{ "bold": true,
  "italic": false,
  "action_item": true }
""".data(using: .utf8)!

let marks = try JSONDecoder().decode([ToolbarMark: Bool].self, from: data)
print(marks)
// => [ToolbarMark.action_item: true, ToolbarMark.bold: true, ToolbarMark.italic: false]

This also works with assigning explicit raw values to your enum cases, so instead of having

enum ToolbarMark: ... {
    ...
    case action_item
}

you can write

enum ToolbarMark: ... {
    ...
    case actionItem = "action_item"
}

to match Swift naming conventions.

4 Likes

Thank you. Appreciate you taking the time write an update and suggestions!

1 Like