Search code examples
iosjsonswiftdecodingdecodable

Simpler method for decoding JSON from multiple services in Swift


Premise

I have a struct that conforms to Decodable, so it can decode JSON from a variety of responses via init(from:). For each type of JSON response I expect to decode, I have an enum that conforms to CodingKey.

Example

Here's a simplified example, which can be dropped into a Swift playground:

import Foundation

// MARK: - Services -

struct Service1 {}
struct Service2 {}

// MARK: - Person Model -

struct Person {
    let name: String
}

extension Person: Decodable {
    enum CodingKeys: String, CodingKey {
        case name = "name"
    }

    enum Service2CodingKeys: String, CodingKey {
        case name = "person_name"
    }

    // And so on through service n...

    init(from decoder: Decoder) throws {
        switch decoder.userInfo[.service] {
        case is Service1.Type:
            let container = try decoder.container(keyedBy: CodingKeys.self)
            name = try container.decode(String.self, forKey: .name)
        case is Service2.Type:
            let container = try decoder.container(keyedBy: Service2CodingKeys.self)
            name = try container.decode(String.self, forKey: .name)
        // And so on through service n...
        default:
            fatalError("Missing implementation for service.")
        }
    }
}

// MARK: - CodingUserInfoKey -

extension CodingUserInfoKey {
    static let service = CodingUserInfoKey(rawValue: "service")!
}

// MARK: - Responses -

// The JSON response from service 1.
let service1JSONResponse = """
[
    {
        "name": "Peter",
    }
]
""".data(using: .utf8)!

// The JSON response from service 2.
let service2JSONResponse = """
[
    {
        "person_name": "Paul",
    }
]
""".data(using: .utf8)!

// And so on through service n... where other services have JSON responses with keys of varied names ("full_name", "personName").

// MARK: - Decoding -

let decoder = JSONDecoder()

decoder.userInfo[.service] = Service1.self
let service1Persons = try decoder.decode([Person].self, from: service1JSONResponse)

decoder.userInfo[.service] = Service2.self
let service2Persons = try decoder.decode([Person].self, from: service2JSONResponse)

Problem

The problem I'm running into is that I have a lot of different services that I needed to decode responses from, and a model with many more properties than this simplified example. As the number of services increases, so does the number of cases needed to decode those responses.

Question

How can I simplify my init(from:) implementation to reduce all this code duplication?

Attempts

I've tried storing the correct CodingKey.Type for each service and passing that into container(keyedBy:), but I get this error:

Cannot invoke 'container' with an argument list of type '(keyedBy: CodingKey.Type)'.

init(from decoder: Decoder) throws {
    let codingKeyType: CodingKey.Type

    switch decoder.userInfo[.service] {
    case is Service1.Type: codingKeyType = CodingKeys.self
    case is Service2.Type: codingKeyType = Service2CodingKeys.self
    default: fatalError("Missing implementation for service.")
    }

    let container = try decoder.container(keyedBy: codingKeyType) // ← Error
    name = try container.decode(String.self, forKey: .name)
}

Solution

  • Rather than trying to solve this with CodingKeys and an increasingly complicated init, I suggest composing it via a protocol:

    protocol PersonLoader: Decodable {
        var name: String { get }
        // additional properties
    }
    
    extension Person {
        init(loader: PersonLoader) {
            self.name = loader.name
            // additional properties, but this is one-time
        }
    }
    

    Alternately, particularly if Person is a read-only simple data object, you could just make Person a protocol, and then you could avoid this extra copying step.

    You can then define the interfaces for each service independently:

    struct Service1Person: PersonLoader {
        let name: String
    }
    
    struct Service2Person: PersonLoader {
        let person_name: String
    
        var name: String { person_name }
    }
    

    And then map into Persons when you're done:

    let service2Persons = try decoder.decode([Service2Person].self,
                                             from: service2JSONResponse)
        .map(Person.init)
    

    If you went with a protocol-only approach, it would look like this instead:

    protocol Person: Decodable {
        var name: String { get }
        // additional properties
    }
    
    struct Service1Person: Person {
        let name: String
    }
    
    struct Service2Person: Person {
        var name: String { person_name }
        let person_name: String
    }
    
    let service2Personsx = try decoder.decode([Service2Person].self,
                                             from: service2JSONResponse) as [Person]