5

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),
]
Florian Ldt
  • 1,125
  • 3
  • 13
  • 31
  • What is `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 reads `value` except the encoder.) – Rob Napier Feb 06 '20 at 14:10

2 Answers2

5

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)
Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • thank you for your answer I really like the first approach. Despite working very well in his context, let's say that `value` can be optional. I could make it work really easily with my approach but have some issue with yours. Is it possible to pass an optional `T` at the initialization of `Dog`? – Florian Ldt Feb 07 '20 at 03:41
  • Change `value: T` to `value: T?` and use `encodeIfPresent` in that case. I'm not sure what you mean by "some issue" here. – Rob Napier Feb 07 '20 at 14:00
  • I edited the question with the issue using `Optionals` in your first solution. – Florian Ldt Feb 08 '20 at 04:13
  • 1
    I made a stupid assumption resulting to the issue I had. Fixed it myself. Thank you for your help. – Florian Ldt Feb 08 '20 at 05:08
  • 1
    Really common mistake there! I sometimes forget that an empty array also can't guess what type it is. `var xs = []` doesn't work… :D – Rob Napier Feb 08 '20 at 14:16
4

Here is another solution that might helps:

struct Dog<V: Codable>: Codable {
   let id: String
   let value: V
}
MohyG
  • 1,335
  • 13
  • 25
  • 1
    I suppose with only this you can’t build an heterogeneous array of Dogs can you? – Florian Ldt Feb 08 '20 at 07:21
  • That is right, I use this approach for only my base response which has a fixed object and a variable data. If you need a heterogeneous array I think you should use [AnyCodable](https://github.com/yonaskolb/Codability/blob/master/Sources/Codability/AnyCodable.swift) instead. – MohyG Feb 08 '20 at 07:44
  • Yes `AnyCodable` is leveraging `Type-erasing` which is quite similar to my `AnyDog` approach. – Florian Ldt Feb 08 '20 at 07:53