Search code examples
swiftcodable

Swift: Custom Encoder/Decoder fails to decode array, found string instead of an array


Because of [AnyHashable: Any] (I need it) I have to implement init(from:) and encode(to:). But when I run it it fails to decode the property with array value:

typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [_JSONKey(stringValue: "items", intValue: nil)], debugDescription: "Expected to decode String but found an array instead.", underlyingError: nil))

This is the code you can run in Playground:

struct ServerResponse: Codable {
    var headers: [AnyHashable: Any]?
    var items: [Item]?
    
    enum CodingKeys: String, CodingKey {
        case items, headers
    }
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: ServerResponse.CodingKeys.self)
        items = try container.decode([Item].self, forKey: ServerResponse.CodingKeys.items)
        let singleValueContainer = try decoder.singleValueContainer()
        let stringDictionary = try singleValueContainer.decode([String: String].self)
        headers = [:]
        for (key, value) in stringDictionary {
            headers?[key] = value
        } 
    }
    
    public func encode(to encoder: Encoder) throws {
        let stringDictionary: [String: String] = Dictionary(
            uniqueKeysWithValues: headers?.map {("\($0)", "\($1)")} ?? []
        )
        var singleValueContainer = encoder.singleValueContainer()
        try singleValueContainer.encode(stringDictionary)
        
        var container = encoder.container(keyedBy: ServerResponse.CodingKeys.self)
        try container.encode(items, forKey: ServerResponse.CodingKeys.items)
    }

    struct Item: Codable {
        let name: String
    }
}

let testData = """
    {
        "items": [
                    {"name": "John"},
                    {"name": "Duo"}
                ]
    }
    """.data(using: .utf8)!
let decoder = JSONDecoder()
do {
    let response = try decoder.decode(ServerResponse.self, from: testData)
    print(response)
} catch {
    print(error)
}

What is wrong with it? Why does it complain about getting String while I have put an array? If I remove headers from the struct and conform to Codable everything works fine.


Solution

  • The issue here lies in how you are attempting to extract items from this top-level dictionary, by attempting to decode it as a dictionary. Specifically,

    let singleValueContainer = try decoder.singleValueContainer()
    let stringDictionary = try singleValueContainer.decode([String: String].self)
    

    is the problematic snippet. With your particular JSON payload, singleValueContainer here wraps up

    {
        "items": [ ... ],
        "..." // <- assuming there are other actual keys and values
    }
    

    This is valid to do, but when you attempt to decode the contents of the container as [String: String], you are asserting that you expect the container to contain specifically a dictionary with String keys and String values; the value for the items key, however, is not a string, but an array.

    When you have a collection with arbitrary values, the correct approach to extracting its contents is to use a keyed container instead. Specifically, you can use a keyed container whose key type can take on any String or Int value, as such:

    struct AnyCodingKey: CodingKey {
        let intValue: Int?
        let stringValue: String
    
        init?(intValue: Int) {
            self.intValue = intValue
            self.stringValue = "\(intValue)"
        }
    
        init?(stringValue: String) {
            intValue = Int(stringValue)
            self.stringValue = stringValue
        }
    }
    

    With this coding key type, you can then request decoder as another keyed container — and this time, the keys can be arbitrary:

    let untypedContainer = try decoder.container(keyedBy: AnyCodingKey.self)
    

    The trick is that now, the values in untypedContainer are not asserted to be of any type until you try to decode them. You can then iterate over untypedContainer.allKeys (same as you iterate over stringDictionary at the moment), and for each key, you can decide how you want to decode(_:forKey:) out of the container. You could:

    1. Attempt to decode a String, and if you get a DecodinerError.typeMismatch error, just skip the key-value pair
    2. Inspect the value of each key, and if you have known keys (or keys which match some pattern, or similar) you're looking for, decode only those instead

    For example:

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: ServerResponse.CodingKeys.self)
        items = try container.decode([Item].self, forKey: ServerResponse.CodingKeys.items)
    
        headers = [:]
        let untypedContainer = try decoder.container(keyedBy: AnyCodingKey.self)
        for key in untypedContainer.allKeys {
            do {
                let value = try untypedContainer.decode(String.self, forKey: key)
                headers[key.stringValue] = value
            } catch DecodingError.typeMismatch { /* skip this key-value pair */ }
        }
    }