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.
Edit #0 2020/02/08: Optional Value
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}]
Edit #1 2020/02/08: Solution
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),
]
Dog
used for other than JSON serialization, if anything? That should drive your design. What consumes this JSON such that it doesn't care what type Value is? (Does the consumer really not care, or do you really have a system that has specific combinations of id's and value types?) If you really only want this for encoding, there are much simpler approaches that don't require all this type erasing or extra types. (By "only for encoding" I mean that nothing readsvalue
except the encoder.) – Paraselene