Using Decodable with Dynamic Values in Swift

APIs suck sometimes. As a Swift developer, I want to create a Decodable model to represent the exact response I'll be receiving from the API so I can maintain type-safety in my project. However, backend devs are sometimes like, "LOL, nah!", and decide to send different data types under the same JSON key.

Models can only conform to Decodable if all the models' properties conform to Decodable too. If you try to decode some JSON, but the value is a different type than what is specified in your model, the decode will fail and all the information is thrown out.

In this tutorial, we will create a flexible type that handles dynamic values, conforms to Decodable, and keeps everything type-safe.

If you're working with an API that gives you this problem, I'd recommend talking with the backend dev to change the API response. I think dynamic values in JSON are bad practice and believe the issue should be fixed at the source.

[
    {
        "name": "Rick Sanchez",
        "age": 70
    },
    {
        "name": "Morty Smith",
        "age": "14"
    }
]

In the JSON snippet above, two user objects are returned in an array, but one object is returning age as an Int and the other as a String.

Create a Swift model to represent the ideal User object for decoding the JSON:

struct User: Decodable {
    let name: String
    let age: Int
}

The User object in the snippet above would work great if all the objects in the array have age as an Int, like Rick. Unfortunately, some objects are coming back with age as a String and will cause the decode to fail. Dammit, Morty!

This issue can be solved by manually implementing init(from decoder:) for User like so:

... // let age: Int

enum CodingKeys: String, CodingKey {
    case name
    case age
}

// 1
init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decode(String.self, forKey: .name)
    
    // 2
    if let ageInt = try? container.decode(Int.self, forKey: .age) {
        self.age = ageInt

    // 3
    } else {
        let ageString = try container.decode(String.self, forKey: .age)
        self.age = Int(ageString)!
    }
}

... // User closing }

Here's what's going on above:

  1. The init(from decoder:) is manually implemented since the default decoding behavior wont work for the JSON being returned from the API.
  2. Attempt to decode age as an Int, then set the value directly from ageInt.
  3. If age cannot be decoded as an Int, attempt to decode it as a String, then set the value by forcefully converting ageString into an Int.

This approach will work, but is not scalable and must be implemented for each object that can decode dynamic values. No, thanks 🙅🏽‍♂️

It would be better to create a single object that could handle the decoding logic and convert any alternative values into the ideal value.

Start by creating a protocol that conforms to Decodable and has a method for converting one type into another type:

protocol Flexible: Decodable {
    func convert<Output: Decodable>(to output: Output.Type) -> Output
}

The Flexible protocol has a generic method convert(to output:) that converts the current type into a different type.

Now create a generic Decodable object that takes an ideal and alternative type to decode dynamic values:

// 1
struct Flex<Value: Decodable, AltValue: Flexible>: Decodable {

    // 2
    let value: Value

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        // 3
        if let value = try? container.decode(Value.self) {
            self.value = value

        // 4
        } else {
            let altValue = try container.decode(AltValue.self)
            self.value = altValue.convert(to: Value.self)
        }
    }
}

Let's break this down:

  1. Flex has two generic parameters, Value and AltValue. Value represents what the ideal value should be for this object and is Decodable. AltValue is an alternative value that can be decoded and converted into the ideal value since it is Flexible.
  2. This property will store the ideal value for the object based on type inferred by Value.
  3. Attempt to decode the data into the ideal type and set the value accordingly.
  4. If the data is not the ideal type, attempt to decode the data into the alternate type. Then convert altValue into the ideal value using convert(to output:), provided through Flexible.

Before being able to use Flex for User.age, String needs to conform to Flexible since it will be considered the AltValue when trying to decode age in the JSON.

Add the following String extension:

extension String: Flexible {
    func convert<Output: Decodable>(to output: Output.Type) -> Output {
        switch output {
        case is Int.Type:
            return Int(self)! as! Output
        default:
            fatalError()
        }
    }
}

By making String conform to Flexible, convert(to output:) can be called on any String and turn the value of the String instance into the specified type, assuming the implementation allows for the specified conversion.

The conversion can be done a number of ways. You may also consider providing a default value if Int(self) fails or modify the function to throw errors that can be handled downstream.

The User model can now be updated to handle dynamic types for age. Replace the User model with the following:

struct User: Decodable {
    let name: 

    // 1
    private let ageFlex: Flex<Int, String>

    // 2
    var age: Int { ageFlex.value }

    enum CodingKeys: String, CodingKey {
        case name

        // 3
        case ageFlex = "age"
    }
}

This is what's changed:

  1. ageFlex is a new private property that will store the decoded value from the JSON. Flex<Int, String> indicates that Int is the ideal type, but it can also decode String values and convert them into Int.
  2. age has been converted to a computed property that exposes the ideal value of ageFlex.
  3. Since age is now a computed property, ageFlex will now be the property responsible for decoding the JSON value for age.

The JSON can now be successfully decoded 🥳

Now that Flex has been implemented, decoding dynamic values in JSON is no problem as long as we make the alternative type Flexible. You're free to take on all the 💩 APIs and keep your Swift code ✨