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:
- The
init(from decoder:)is manually implemented since the default decoding behavior wont work for the JSON being returned from the API. - Attempt to decode
ageas anInt, then set the value directly fromageInt. - If
agecannot be decoded as anInt, attempt to decode it as aString, then set the value by forcefully convertingageStringinto anInt.
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:
Flexhas two generic parameters,ValueandAltValue.Valuerepresents what the ideal value should be for this object and isDecodable.AltValueis an alternative value that can be decoded and converted into the ideal value since it isFlexible.- This property will store the ideal value for the object based on type inferred by
Value. - Attempt to decode the data into the ideal type and set the value accordingly.
- If the data is not the ideal type, attempt to decode the data into the alternate type. Then convert
altValueinto the ideal value usingconvert(to output:), provided throughFlexible.
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:
ageFlexis a newprivateproperty that will store the decoded value from the JSON.Flex<Int, String>indicates thatIntis the ideal type, but it can also decodeStringvalues and convert them intoInt.agehas been converted to a computed property that exposes the ideal value ofageFlex.- Since
ageis now a computed property,ageFlexwill now be the property responsible for decoding the JSON value forage.
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 ✨