0

I have a Swift struct that looks like this:

struct MyStruct: Codable {

  var id: String
  var name: String
  var createdDate: Date

}

To that, I would like to add another property: a [String:Any] dictionary. The result would look like this:

struct MyStruct: Codable {

  var id: String
  var name: String
  var createdDate: Date

  var attributes: [String:Any] = [:]
}

In the end, I would like to be able to serialize my MyStruct instance to a JSON string, and vice versa. However, when I go to build I get an error saying,

Type 'MyStruct' does not conform to protocol 'Codable'
Type 'MyStruct' does not conform to protocol 'Decodable'

It's clearly the attributes var that is tripping up my build, but I'm not sure how I can get the desired results. Any idea how I can code my struct to support this?

6
  • 4
    You can’t encode/decode Any so you need to replace it with a real type. Commented Jan 26, 2021 at 21:34
  • 3
    Any and generics are two completely different things. Commented Jan 26, 2021 at 21:35
  • 1
    When you say that this is [String: Any], that suggests that any type would be acceptable here, and you'd want to encode and decode it. For example, I could put a UIViewController in that dictionary, or a CBPeripheral (which doesn't even have a public init). Those don't seem like proper things for an "attributes" dictionary. I suspect you have some smaller list of things that make sense for attributes, so you'll want to create a type for that. Commented Jan 26, 2021 at 21:40
  • @vadian I didn't mean generic as in "generics". I meant, as in I could have multiple different data types within that dictionary. Maybe it's got Strings. Maybe Ints. Maybe another object. I'd like to be able to encode/decode that dictionary to and from JSON. Commented Jan 26, 2021 at 21:40
  • 2
    If you mean "any type that would be appropriate for JSON," take a look at stackoverflow.com/a/65902852/97337 for a discussion of that. Commented Jan 26, 2021 at 21:41

1 Answer 1

1

Since the comments already point out that Any type has nothing to do with generics, let me jump straight into the solution.

First thing you need is some kind of wrapper type for your Any attribute values. Enums with associated values are great for that job. Since you know best what types are to be expected as an attribute, feel free to add/remove any case from my sample implementation.

enum MyAttrubuteValue {
    case string(String)
    case date(Date)
    case data(Data)
    case bool(Bool)
    case double(Double)
    case int(Int)
    case float(Float)
}

We will be later wrapping attribute values from the [String: Any] dictionary into the wrapper enum cases, but first we need to make the type conform to the Codable protocols. I am using singleValueContainer() for the decoding/encoding so the final json will produce a regular json dicts.

extension MyAttrubuteValue: Codable {

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let string = try? container.decode(String.self) {
            self = .string(string)
        } else if let date = try? container.decode(Date.self) {
            self = .date(date)
        } else if let data = try? container.decode(Data.self) {
            self = .data(data)
        } else if let bool = try? container.decode(Bool.self) {
            self = .bool(bool)
        } else if let double = try? container.decode(Double.self) {
            self = .double(double)
        } else if let int = try? container.decode(Int.self) {
            self = .int(int)
        } else if let float = try? container.decode(Float.self) {
            self = .float(float)
        } else {
            fatalError()
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .string(let string):
            try? container.encode(string)
        case .date(let date):
            try? container.encode(date)
        case .data(let data):
            try? container.encode(data)
        case .bool(let bool):
            try? container.encode(bool)
        case .double(let double):
            try? container.encode(double)
        case .int(let int):
            try? container.encode(int)
        case .float(let float):
            try? container.encode(float)
        }
    }

}

At this point we are good to go, but before we will decode/encode the attributes, we can use some extra interoperability between [String: Any] and [String: MyAttrubuteValue] types. To map easily between Any and MyAttrubuteValue lets add the following:

extension MyAttrubuteValue {

    var value: Any {
        switch self {
        case .string(let value):
            return value
        case .date(let value):
            return value
        case .data(let value):
            return value
        case .bool(let value):
            return value
        case .double(let value):
            return value
        case .int(let value):
            return value
        case .float(let value):
            return value
        }
    }

    init?(_ value: Any) {
        if let string = value as? String {
            self = .string(string)
        } else if let date = value as? Date {
            self = .date(date)
        } else if let data = value as? Data {
            self = .data(data)
        } else if let bool = value as? Bool {
            self = .bool(bool)
        } else if let double = value as? Double {
            self = .double(double)
        } else if let int = value as? Int {
            self = .int(int)
        } else if let float = value as? Float {
            self = .float(float)
        } else {
            return nil
        }
    }

}

Now, with the quick value access and new init, we can map values easily. We are also making sure that the helper properties are only available for the dictionaries of concrete types, the ones we are working with.

extension Dictionary where Key == String, Value == Any {
    var encodable: [Key: MyAttrubuteValue] {
        compactMapValues(MyAttrubuteValue.init)
    }
}

extension Dictionary where Key == String, Value == MyAttrubuteValue {
    var any: [Key: Any] {
        mapValues(\.value)
    }
}

Now the final part, a custom Codable implementation for MyStruct

extension MyStruct: Codable {

    enum CodingKeys: String, CodingKey {
        case id = "id"
        case name = "name"
        case createdDate = "createdDate"
        case attributes = "attributes"
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
        try container.encode(createdDate, forKey: .createdDate)
        try container.encode(attributes.encodable, forKey: .attributes)
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        createdDate = try container.decode(Date.self, forKey: .createdDate)
        attributes = try container.decode(
            [String: MyAttrubuteValue].self, forKey: .attributes
        ).any
    }

}

This solution is fairly long, but pretty straight-forward at the same time. We lose automatic Codable implementation, but we got exactly what we wanted. Now you are able to encode ~Any~ type that already conforms to Codable easily, by adding an extra case to your new MyAttrubuteValue enum. One final thing to say is that we use similar approach to this one in production, and we have been happy so far.

That's a lot of code, here is a gist.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.