I was looking for, in a struct, having a way of having a generic property where the type is defined at runtime like:
struct Dog {
let id: String
let value: ??
}
A simple use case where this can be useful is when building a json
object. A node
can be an int
,string
, bool
, an array, etc., but apart from the type which can change, the object node
stay the same.
After thinking a bit and failing using protocols
(got the usual protocol 'X' can only be used as a generic constraint because it has Self or associated type requirements
error), I came up with 2 different solutions, #0 using type erasure
and #1 using type-erasure
and generics
.
#0 (type-erasure)
struct AnyDog: Encodable {
enum ValueType: Encodable {
case int(Int)
case string(String)
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .int(let value):
try container.encode(value)
case .string(let value):
try container.encode(value)
}
}
}
let id: String
let value: ValueType
init(_ dog: DogString) {
self.id = dog.id
self.value = .string(dog.value)
}
init(_ dog: DogInt) {
self.id = dog.id
self.value = .int(dog.value)
}
}
struct DogString: Encodable{
let id: String
let value: String
var toAny: AnyDog {
return AnyDog(self)
}
}
struct DogInt: Encodable {
let id: String
let value: Int
var toAny: AnyDog {
return AnyDog(self)
}
}
let dogs: [AnyDog] = [
DogString(id: "123", value: "pop").toAny,
DogInt(id: "123", value: 123).toAny,
]
do {
let data = try JSONEncoder().encode(dogs)
print(String(data: data, encoding: .utf8)!)
} catch {
print(error)
}
#1 (type-erasure + generics)
struct AnyDog: Encodable {
enum ValueType: Encodable {
case int(Int)
case string(String)
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .int(let value):
try container.encode(value)
case .string(let value):
try container.encode(value)
}
}
}
let id: String
let value: ValueType
}
struct Dog<T: Encodable>: Encodable{
let id: String
let value: T
var toAny: AnyDog {
switch T.self {
case is String.Type:
return AnyDog(id: id, value: .string(value as! String))
case is Int.Type:
return AnyDog(id: id, value: .int(value as! Int))
default:
preconditionFailure("Invalid Type")
}
}
}
let dogs: [AnyDog] = [
Dog<String>(id: "123", value: "pop").toAny ,
Dog<Int>(id: "123", value: 123).toAny,
]
do {
let data = try JSONEncoder().encode(dogs)
print(String(data: data, encoding: .utf8)!)
} catch {
print(error)
}
Both approach give the appropriate result:
[{"id":"123","value":"pop"},{"id":"123","value":123}]
Even if the result is identical, I strongly believe that approach #1 is the more scalable
on if more types are take into account, but there is still changes to be made at 2 different area for each type added.
I am sure there is a better way to achieve this but wasn't able to find it yet. Would be happy to hear any thoughts or suggestions about it.
Using on Rob's great answer, I am now trying to allow value
to be optional like so:
struct Dog: Encodable {
// This is the key to the solution: bury the type of value inside a closure
let valueEncoder: (Encoder) throws -> Void
init<T: Encodable>(id: String, value: T?) {
self.valueEncoder = {
var container = $0.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(value, forKey: .value)
}
}
enum CodingKeys: String, CodingKey {
case id, value
}
func encode(to encoder: Encoder) throws {
try valueEncoder(encoder)
}
}
let dogs = [
Dog(id: "123", value: 123),
Dog(id: "456", value: nil),
]
do {
let data = try JSONEncoder().encode(dogs)
print(String(data: data, encoding: .utf8)!)
} catch {
print(error)
}
At this point, T
cannot be inferred anymore and the following error is thrown:
generic parameter 'T' could not be inferred
I am looking for a possibility to use Rob's answer giving the following result if an Optional
type is given for value
:
[{"id":"123","value":123},{"id":"456","value":null}]
Alright I was so focus on giving value
the value nil
that I didn't realized that nil
didn't have any any type resulting to the inference error.
Giving a optional type makes it work:
let optString: String? = nil
let dogs = [
Dog(id: "123", value: 123),
Dog(id: "456", value: optString),
]
If what you've described is really what you want, it can be done without any of these type erasers. All you need is a closure. (But this assumes that Dog
really exists only for encoding, as you've described, and that nothing needs value
outside of that.)
struct Dog: Encodable {
// This is the key to the solution: bury the type of value inside a closure
let valueEncoder: (Encoder) throws -> Void
init<T: Encodable>(id: String, value: T) {
self.valueEncoder = {
var container = $0.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(value, forKey: .value)
}
}
enum CodingKeys: String, CodingKey {
case id, value
}
func encode(to encoder: Encoder) throws {
try valueEncoder(encoder)
}
}
Since value
is only ever used inside of valueEncoder
, the rest of the world doesn't need to know its type (Dog doesn't even need to know its type). This is what type-erasure is all about. It doesn't require making additional wrapper types or generic structs.
If you want to keep around the types like DogString
and DogInt
, you can do that as well by adding a protocol:
protocol Dog: Encodable {
associatedtype Value: Encodable
var id: String { get }
var value: Value { get }
}
And then make a DogEncoder to handle encoding (identical to above, except a new init method):
struct DogEncoder: Encodable {
let valueEncoder: (Encoder) throws -> Void
init<D: Dog>(_ dog: D) {
self.valueEncoder = {
var container = $0.container(keyedBy: CodingKeys.self)
try container.encode(dog.id, forKey: .id)
try container.encode(dog.value, forKey: .value)
}
}
enum CodingKeys: String, CodingKey {
case id, value
}
func encode(to encoder: Encoder) throws {
try valueEncoder(encoder)
}
}
Couple of kinds of dogs:
struct DogString: Dog {
let id: String
let value: String
}
struct DogInt: Dog {
let id: String
let value: Int
}
Put them in an array of encoders:
let dogs = [
DogEncoder(DogString(id: "123", value: "pop")),
DogEncoder(DogInt(id: "123", value: 123)),
]
let data = try JSONEncoder().encode(dogs)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With