jsonswiftdecoding

Swift Error Handling Techniques for Decoding JSON as Either Object or Array


There are JSON formats, where a property sometimes is a dictionary, and sometimes an array.

There are several questions about that. This question is about good error handling. This question starts where the accepted answer of Hot to decode JSON data that could and array or a single element in Swift? ends.

Variable data

with only one value, the name data comes as dictionary:

{
"id": "item123",
"name": {
    "@language": "en",
    "@value": "Test Item"
}
}

With two or more values, the name data comes as an array:

{
"id": "item123",
"name": [
    {
        "@language": "en",
        "@value": "Test Item"
    },
    {
        "@language": "de",
        "@value": "Testartikel"
    }
]
}

This is my solution, and it works fine if the Decodable structs match with the JSON data to decode.

struct Item: Codable {
    var id: String
    var name: [LocalizedString]

    enum CodingKeys: String, CodingKey {
        case id
        case name
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)

        if let singleName = try? container.decode(LocalizedString.self, forKey: .name) {
            name = [singleName]
        } else {
            name = try container.decodeIfPresent([LocalizedString].self, forKey: .name) ?? []
        }
    }
}

struct LocalizedString: Codable {
    var language: String
    var value: String

    enum CodingKeys: String, CodingKey {
        case language = "@language"
        case value = "@value"
    }
}

(this idea matches with accepted answers elsewhere)

What's bad about it, is that decoding error messages are not useful any more with this approach.

assume unexpected data like this (@language is missing):

{
    "id": "item123",
    "name": {
        "@value": "Test Item"
    }
}

I get an error message

Error: typeMismatch(Swift.Array, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "name", intValue: nil)], debugDescription: "Expected to decode Array but found a dictionary instead.", underlyingError: nil))

Instead of the correct error message, that key language is missing.

The correct error message is thrown away with the question mark in

if let singleName = try? 

So is there a way to first check if it is an array or not, and then using the fitting

container.decodeIfPresent(LocalizedString.self, forKey: .name)

or

container.decodeIfPresent([LocalizedString].self, forKey: .name)

To get a good error message in any case?

My real world data is much more nested than this simple example, so it gets really hard to find errors when the error message doesn't fit.

Here is a complete testable example for this question:

import XCTest

class ItemDecodingTests: XCTestCase {

    func testDecodeItemWithSingleLocalizedString() throws {
        let jsonData = test_json_data_single.data(using: .utf8)!
        let item = try JSONDecoder().decode(Item.self, from: jsonData)
        XCTAssertNotNil(item)
    }

    func testDecodeItemWithMultipleLocalizedStrings() throws {
        let jsonData = test_json_data_array.data(using: .utf8)!
        let item = try JSONDecoder().decode(Item.self, from: jsonData)
        XCTAssertNotNil(item)
    }
    
    func testDecodeItemWithMissingLanguageProperty() throws {
        let jsonData = test_json_data_missing_language.data(using: .utf8)!
        XCTAssertThrowsError(try JSONDecoder().decode(Item.self, from: jsonData)) { error in
            print("Error: \(error)")
        }
    }
}

// Test JSON Data
let test_json_data_single = """
{
    "id": "item123",
    "name": {
        "@language": "en",
        "@value": "Test Item"
    }
}
"""

let test_json_data_array = """
{
    "id": "item123",
    "name": [
        {
            "@language": "en",
            "@value": "Test Item"
        },
        {
            "@language": "de",
            "@value": "Testartikel"
        }
    ]
}
"""

let test_json_data_missing_language = """
{
    "id": "item123",
    "name": {
        "@value": "Test Item"
    }
}
"""

struct Item: Codable {
    var id: String
    var name: [LocalizedString]

    enum CodingKeys: String, CodingKey {
        case id
        case name
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)

        if let singleName = try? container.decode(LocalizedString.self, forKey: .name) {
            name = [singleName]
        } else {
            name = try container.decodeIfPresent([LocalizedString].self, forKey: .name) ?? []
        }
    }
}

struct LocalizedString: Codable {
    var language: String
    var value: String

    enum CodingKeys: String, CodingKey {
        case language = "@language"
        case value = "@value"
    }
}

Solution

  • You don't get the expected error message because you deliberately ignore the error with try?.

    A better approach is to catch and rethrow the keyNotFound error explicitly before decoding the array

    struct Item: Codable {
        var id: String
        var name: [LocalizedString]
        
        enum CodingKeys: String, CodingKey {
            case id
            case name
        }
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            id = try container.decode(String.self, forKey: .id)
            
            do {
                let singleName = try container.decode(LocalizedString.self, forKey: .name)
                name = [singleName]
            } catch DecodingError.keyNotFound(let key, let context) {
                throw DecodingError.keyNotFound(key, context)
            } catch {
                name = try container.decodeIfPresent([LocalizedString].self, forKey: .name) ?? []
            }
        }
    }