Search code examples
swiftdecodable

Is it possible to leverage Codable to initialize a conforming type from a Dictionary


Suppose I have the following struct definition and dictionary:

struct Point: Codable {
    let x: Int
    let y: Int
}

let pointDictionary = [ "x": 0, "y": 1]

// Inital example was too easy so also we might have
struct Score: Codable {
    let points: Int
    let name: String
}

let scoreDictionary: [String: Any] = [
    "points": 10,
    "name": "iOSDevZone"
]

Is there a way, without a roundtrip through JSON or PList, to populate an instance of the struct Point from pointDictionary?

What I've Tried

I've looked at the Apple docs and can't really find a way.

To be clear, I understand I could write a custom initializer that takes a Dictionary (as I was submitting this question the system matched with an answer that illustrates this), but that's not what I am asking. (And this is not practical in my real situation, this is purely a demonstrative example).

I am asking, given a [String:Any] Dictionary where the keys match the property names of a struct and the values are convertible to the types of the properties, is there a way of leveraging Decodable to initialize the struct?

Why a Dictionary init is not desirable Since most responses have been: "Why not implement a dictionary init?"

There are lots of structs and many properties, the dictionaries come from processing bad JSON (that I have no control over).


Solution

  • This is definitely possible because it's how Foundation does it on Darwin:

    open func decode<T : Decodable>(_ type: T.Type, from data: Data) throws -> T {
        let topLevel: Any
        do {
           topLevel = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
        } catch {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: error))
        }
    
        let decoder = __JSONDecoder(referencing: topLevel, options: self.options)
        guard let value = try decoder.unbox(topLevel, as: type) else {
            throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: [], debugDescription: "The given data did not contain a top-level value."))
        }
    
        return value
    }
    

    You can pass Any to __JSONDecoder and it'll decode it. Unfortunately, __JSONDecoder is private. But it is also open source, so that's fixable. It's just tedious.

    You need to copy roughly 1500 lines of __JSONDecoder implementation and a few supporting types, remove the "private" in front of __JSONDecoder, and then you can add an extension that does what you want:

    extension JSONDecoder {
        fileprivate var options: _Options {
            return _Options(dateDecodingStrategy: dateDecodingStrategy,
                            dataDecodingStrategy: dataDecodingStrategy,
                            nonConformingFloatDecodingStrategy: nonConformingFloatDecodingStrategy,
                            keyDecodingStrategy: keyDecodingStrategy,
                            userInfo: userInfo)
        }
    
        func decode<T : Decodable>(_ type: T.Type, from topLevel: Any) throws -> T {
            let decoder = __JSONDecoder(referencing: topLevel, options: self.options)
            guard let value = try decoder.unbox(topLevel, as: type) else {
                throw DecodingError.valueNotFound(type, DecodingError.Context(codingPath: [], debugDescription: "The given data did not contain a top-level value."))
            }
    
            return value
        }
    }
    
    // And then it "just works":
    let score = try JSONDecoder().decode(Score.self, from: scoreDictionary)