Blog
Blog copied to clipboard
DecodableEither
(Originally published 2019-10-4)
It's not uncommon for the back-end guys to return some JSON where the type of a field is either a number or a string.
This causes inconvenience at the app development level where it's not enough to declare vanilla Codable
structs.
Thankfully, Decodable
makes this way less messy than first imagined.
We can wrap the either logic in a separate Decodable
type that handles this for us:
struct Product: Decodable {
let id: EitherIntOrString
struct EitherIntOrString: Decodable {
let value: Int
init(from decoder: Decoder) throws {
let values = try decoder.singleValueContainer()
do {
value = try values.decode(Int.self)
} catch {
let string = try values.decode(String.self)
guard let int = Int(string) else {
throw ParsingError.stringParsingError
}
value = int
}
}
}
enum ParsingError: Error {
case stringParsingError
}
}
So, JSON like the following parses successfully:
[
{ "id": 12 },
{ "id": "14" }
]
Generalizing
We can make a generic either type out of this.
All we need is two Decodable
types, and a converter from a type to the other.
Let's see:
protocol Converter {
associatedtype T1
associatedtype T2
static func convert(_ t2: T2) -> T1?
}
struct DecodableEither<T1: Decodable, T2: Decodable, C: Converter>: Decodable where C.T1 == T1, C.T2 == T2 {
let value: T1
init(from decoder: Decoder) throws {
let values = try decoder.singleValueContainer()
do {
value = try values.decode(T1.self)
} catch {
let t2 = try values.decode(T2.self)
guard let t1 = C.convert(t2) else {
throw Error.conversionError
}
value = t1
}
}
enum Error: Swift.Error {
case conversionError
}
}
Let's break this down:
-
DecodableEither<T1: Decodable, T2: Decodable, C: Converter>: Decodable
. Here we declare a generic struct that conforms toDecodable
, and depends on three types. The first two are anyDecodable
types. The third is just a type that conforms to a protocol calledConverter
that we will use for converting from typeT2
toT1
. - The
Converter
protocol declares a static function that converts from a generic type to another. Such protocol is called protocol with associated types, commonly called "PATs". - Now, the Swiftiest thing in this code, the type constraints.
where C.T1 == T1, C.T2 == T2
. This part after theDecodableEither
declaration is what ensures type-safety and makes things work together. Here we tell the Swift compiler to ensure that anyConverter
type passed to us here must have its two associated types be the very two types passed to theDecodableEither
. This what makes that linelet t1 = C.convert(t2)
in the alternate decoding phase work, and infer correctly the given types.
Now, we can use this generic type like this:
enum StringToIntConverter: Converter {
static func convert(_ t2: String) -> Int? {
return Int(t2)
}
}
struct Product: Decodable {
let id: DecodableEither<Int, String, StringToIntConverter>
}
Usage:
let json = """
[
{ "id": 12 },
{ "id": "14" }
]
"""
let products = try! JSONDecoder().decode([Product].self, from: json.data(using: .utf8)!)
products.forEach({
print($0.id.value) // value is Int 👍
})
We can also use typealisases if a particular combination is used frequently:
typealias DecodableEitherIntOrString = DecodableEither<Int, String, StringToIntConverter>
struct Product: Decodable {
let id: DecodableEitherIntOrString
}
That's it. Thanks for reading!
Update (16-10-2019)
I stumbled upon this brilliant suggestion by Jussi Laitinen.
Now, our solution can be cleaner by eliminating the third Converter
type, and instead requiring our first type to be convertible from the second type. Let's see this in code:
protocol Convertible {
associatedtype T
init?(_ value: T)
}
struct DecodableEither<T1: Decodable & Convertible, T2: Decodable>: Decodable where T1.T == T2 {
let value: T1
init(from decoder: Decoder) throws {
let values = try decoder.singleValueContainer()
do {
value = try values.decode(T1.self)
} catch {
let t2 = try values.decode(T2.self)
guard let t1 = T1(t2) else {
throw Error.conversionError
}
value = t1
}
}
enum Error: Swift.Error {
case conversionError
}
}
Also, converting from String
to Int
is a lot simpler now, since Int
already has a failable initializer that accepts a String
. We just extend Int
to conform to our Convertible
protocol while stating that the generic/associated type T
to be String
.
extension Int: Convertible {
typealias T = String
}
Update (29-04-2020)
Fadi suggested a more generic solution to this problem that leaves the converting step to the user. I like it. Here it is:
enum DecodableEither<T1: Decodable, T2: Decodable>: Decodable {
case v1(T1)
case v2(T2)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let v1 = try? container.decode(T1.self) {
self = .v1(v1)
} else {
self = try .v2(container.decode(T2.self))
}
}
var v1: T1? {
switch self {
case .v1(let value): return value
default: return nil
}
}
var v2: T2? {
switch self {
case .v2(let value): return value
default: return nil
}
}
}