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"
}
}
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) ?? []
}
}
}