Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

struct with generic property conforming to Encodable in Swift

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),
]
like image 809
Florian Ldt Avatar asked Mar 03 '23 20:03

Florian Ldt


1 Answers

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)
like image 160
Rob Napier Avatar answered Mar 05 '23 15:03

Rob Napier