arraysjsonswiftswift4codable

Swift JSONDecode decoding arrays fails if single element decoding fails


While using Swift4 and Codable protocols I got the following problem - it looks like there is no way to allow JSONDecoder to skip elements in an array. For example, I have the following JSON:

[
    {
        "name": "Banana",
        "points": 200,
        "description": "A banana grown in Ecuador."
    },
    {
        "name": "Orange"
    }
]

And a Codable struct:

struct GroceryProduct: Codable {
    var name: String
    var points: Int
    var description: String?
}

When decoding this json

let decoder = JSONDecoder()
let products = try decoder.decode([GroceryProduct].self, from: json)

Resulting products is empty. Which is to be expected, due to the fact that the second object in JSON has no "points" key, while points is not optional in GroceryProduct struct.

Question is how can I allow JSONDecoder to "skip" invalid object?


Solution

  • One option is to use a wrapper type that attempts to decode a given value; storing nil if unsuccessful:

    struct FailableDecodable<Base : Decodable> : Decodable {
    
        let base: Base?
    
        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            self.base = try? container.decode(Base.self)
        }
    }
    

    We can then decode an array of these, with your GroceryProduct filling in the Base placeholder:

    import Foundation
    
    let json = """
    [
        {
            "name": "Banana",
            "points": 200,
            "description": "A banana grown in Ecuador."
        },
        {
            "name": "Orange"
        }
    ]
    """.data(using: .utf8)!
    
    
    struct GroceryProduct : Codable {
        var name: String
        var points: Int
        var description: String?
    }
    
    let products = try JSONDecoder()
        .decode([FailableDecodable<GroceryProduct>].self, from: json)
        .compactMap { $0.base } // .flatMap in Swift 4.0
    
    print(products)
    
    // [
    //    GroceryProduct(
    //      name: "Banana", points: 200,
    //      description: Optional("A banana grown in Ecuador.")
    //    )
    // ]
    

    We're then using .compactMap { $0.base } to filter out nil elements (those that threw an error on decoding).

    This will create an intermediate array of [FailableDecodable<GroceryProduct>], which shouldn't be an issue; however if you wish to avoid it, you could always create another wrapper type that decodes and unwraps each element from an unkeyed container:

    struct FailableCodableArray<Element : Codable> : Codable {
    
        var elements: [Element]
    
        init(from decoder: Decoder) throws {
    
            var container = try decoder.unkeyedContainer()
    
            var elements = [Element]()
            if let count = container.count {
                elements.reserveCapacity(count)
            }
    
            while !container.isAtEnd {
                if let element = try container
                    .decode(FailableDecodable<Element>.self).base {
    
                    elements.append(element)
                }
            }
    
            self.elements = elements
        }
    
        func encode(to encoder: Encoder) throws {
            var container = encoder.singleValueContainer()
            try container.encode(elements)
        }
    }
    

    You would then decode as:

    let products = try JSONDecoder()
        .decode(FailableCodableArray<GroceryProduct>.self, from: json)
        .elements
    
    print(products)
    
    // [
    //    GroceryProduct(
    //      name: "Banana", points: 200,
    //      description: Optional("A banana grown in Ecuador.")
    //    )
    // ]