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
age
as anInt
, then set the value directly fromageInt
. - If
age
cannot be decoded as anInt
, attempt to decode it as aString
, then set the value by forcefully convertingageString
into 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:
Flex
has two generic parameters,Value
andAltValue
.Value
represents what the ideal value should be for this object and isDecodable
.AltValue
is 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
altValue
into 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:
ageFlex
is a newprivate
property that will store the decoded value from the JSON.Flex<Int, String>
indicates thatInt
is the ideal type, but it can also decodeString
values and convert them intoInt
.age
has been converted to a computed property that exposes the ideal value ofageFlex
.- Since
age
is now a computed property,ageFlex
will 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 ✨