Decoding a class based on encoded value Swift

Viewed 71

I'm trying to decode a specific class based on the contents of the encoded data.

class Vehicle: Codable {
    enum Kind: Int, Codable {
        case car = 0, motorcycle = 1
    }
    
    let brand: String
    let numberOfWheels: Int
}

class Car: Vehicle {}
class MotorCycle: Vehicle {}

As you can see, I have a generic Vehicle type used to encode and decode vehicles. This works fine for basic decoding shown below.

let car = "{\"kind\": 0, \"brand\": \"Ford\", \"number_of_wheels\": 4}".data(using: .utf8)!
let motorCycle = "{\"kind\": 1, \"brand\": \"Yamaha\", \"number_of_wheels\": 2}".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

// Outputs a Project.Car
let ford = try! decoder.decode(Car.self, from: car)
// Outputs a Project.MotorCycle
let yamaha = try! decoder.decode(MotorCycle.self, from: motorCycle)

But what if I want to decode an array of vehicles, but have them decoded as a specific type?

let combined = "[{\"kind\": 0, \"brand\": \"Ford\", \"number_of_wheels\": 4}, {\"kind\": 1, \"brand\": \"Yamaha\", \"number_of_wheels\": 2}]".data(using: .utf8)!

// Outputs [Project.Vehicle, Project.Vehicle]
print(try! decoder.decode([Vehicle].self, from: combined))

How can I have the decoder output an array of vehicles, but typed vehicles, by using the kind property in the JSON data. As per example [Project.Car, Project.MotorCycle] if possible at all.

2 Answers

Here is an alternative solution that doesn't use Codable. Also I made some changes and introduced a protocol instead of a super class.

protocol Vehicle: CustomStringConvertible {
    var brand: String { get set }
    var numberOfWheels: Int { get set }
}

extension Vehicle {
    var description: String {
        "\(brand), wheels: \(numberOfWheels), type: \(type(of:self))"
    }
}

Not so important but I changed the types from class to struct

struct Car: Vehicle {
    var brand: String
    var numberOfWheels: Int
}

struct MotorCycle: Vehicle {
    var brand: String
    var numberOfWheels: Int
}

And then converted the json to a Vehicle array in two steps using JSONSerialization for the decoding and then reduce(into:) to create the objects

do {
    if let array = try JSONSerialization.jsonObject(with: combined) as? [[String: Any]] {
        let vehicles = array.reduce(into: [Vehicle]()) {
            if let kindValue = $1["kind"] as? Int,
               let kind = VehicleKind(rawValue: kindValue),
               let brand = $1["brand"] as? String,
               let numberOfWheels = $1["number_of_wheels"] as? Int {
                switch kind {
                case .car:
                    $0.append(Car(brand: brand, numberOfWheels: numberOfWheels))
                case .motorcycle:
                    $0.append(MotorCycle(brand: brand, numberOfWheels: numberOfWheels))
                }
            }
        }
        for vehicle in vehicles {
            print(vehicle)
        }
    }
} catch {
    print(error)
}

The above code outputs:

Ford, wheels: 4, type: Car
Yamaha, wheels: 2, type: MotorCycle


Update. Codable version

I managed to come up with a Codable solution as well by introducing a separate type to use for decoding. The set up is the same as before with a protocol and two structs.

Then I introduce a specific type for decoding (but it can of course be extended for encoding as well) that implements a custom init(from:)

struct JsonVehicle: Decodable {
    let vehicle: Vehicle

    enum CodingKeys: String, CodingKey {
        case kind
        case brand
        case numberOfWheels
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        let kind = try container.decode(VehicleKind.self, forKey: .kind)
        let brand = try container.decode(String.self, forKey: .brand)
        let wheels = try container.decode(Int.self, forKey: .numberOfWheels)
        switch kind {
        case .car:
            vehicle = Car(brand: brand, numberOfWheels: wheels)
        case .motorcycle:
            vehicle = MotorCycle(brand: brand, numberOfWheels: wheels)
        }
    }
}

And again the end result is achieved in 2 steps

do {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase

    let result = try decoder.decode([JsonVehicle].self, from: combined)
    let vehicles = result.map(\.vehicle)
} catch {
    print(error)
}

You have no kind property defined in class. In addition, there is no difference between Car and MotorCycle I tested this in playground, seems to do what you want.

    class Vehicle: Codable {
        enum Kind: Int, Codable {
            case car = 0, motorcycle = 1
        }
        let kind: Kind
        let brand: String
        let numberOfWheels: Int
    }
    
    class Car: Vehicle {}
    class MotorCycle: Vehicle {}

    let car = "{\"kind\": 0, \"brand\": \"Ford\", \"number_of_wheels\": 4}".data(using: .utf8)!
    let motorCycle = "{\"kind\": 1, \"brand\": \"Yamaha\", \"number_of_wheels\": 2}".data(using: .utf8)!
    
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    
    // Outputs a Project.Car
    let ford = try! decoder.decode(Car.self, from: car)
    // Outputs a Project.MotorCycle
    let yamaha = try! decoder.decode(MotorCycle.self, from: motorCycle)
    //But what if I want to decode an array of vehicles, but have them decoded as a specific type?
    
    let combined = "[{\"kind\": 0, \"brand\": \"Ford\", \"number_of_wheels\": 4}, {\"kind\": 1, \"brand\": \"Yamaha\", \"number_of_wheels\": 2}]".data(using: .utf8)!
    
    // Outputs [Project.Vehicle, Project.Vehicle]
    let allVeh = try! decoder.decode([Vehicle].self, from: combined)
    for veh in allVeh where veh.kind == Vehicle.Kind.motorcycle { // } is MotorCycle {
        print(veh.brand, type(of: veh))
    }

It gives: Yamaha Vehicle

You could also write

let allMotorcycles = allVeh.filter {$0.kind == Vehicle.Kind.motorcycle}
for moto in allMotorcycles  {
    print(moto.brand, type(of: moto))
}
Related