struct with generic property conforming to Encodable in Swift
Asked Answered
B

2

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),
]
Buffy answered 6/2, 2020 at 13:48 Comment(1)
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.)Paraselene
P
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)
Paraselene answered 6/2, 2020 at 14:17 Comment(5)
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?Buffy
Change value: T to value: T? and use encodeIfPresent in that case. I'm not sure what you mean by "some issue" here.Paraselene
I edited the question with the issue using Optionals in your first solution.Buffy
I made a stupid assumption resulting to the issue I had. Fixed it myself. Thank you for your help.Buffy
Really common mistake there! I sometimes forget that an empty array also can't guess what type it is. var xs = [] doesn't work… :DParaselene
L
4

Here is another solution that might helps:

struct Dog<V: Codable>: Codable {
   let id: String
   let value: V
}
Lifeline answered 8/2, 2020 at 7:18 Comment(3)
I suppose with only this you can’t build an heterogeneous array of Dogs can you?Buffy
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 instead.Lifeline
Yes AnyCodable is leveraging Type-erasing which is quite similar to my AnyDog approach.Buffy

© 2022 - 2024 — McMap. All rights reserved.