David Henry
David Henry

Reputation: 3054

How to Decode an Enum of Structs in Swift

Apologies for the long question.

I am using Firestore to store online data and have a current structure like the below;

{
  "activities": { 
    "mG47rRED9Ym4dkXinXrN": {
      "createdAt": 1234567890,
      "activityType": {
        "title": "Some Title"
      }
    },
    "BF3jhINa1qu9kia00BeG": {
      "createdAt": 1234567890,
      "activityType": {
        "percentage": 50,
      }
    }
  }
}

I am using JSON Decodable Protocol to retrieve the data. I have a main struct of;

struct Activity: Decodable {
    let documentID: String
    let createdAt: Int
    let activityType: ActivityType
}

this struct holds the mandatory data such as createdAt & documentID (i.e. "mG47rRED9Ym4dkXinXrN"). Depending upon the data nested inside "activityType" it should return one of two structs listed below;

struct NewGoal: Decodable {
    let title: String
}

struct GoalAchieved: Decodable {
    let percentage: Double
}

I am doing this with decodable enumeration;

enum ActivityType: Decodable {
    case newGoal(NewGoal)
    case goalAchieved(GoalAchieved)
}

extension ActivityType {

    private enum CodingKeys: String, CodingKey {
        case activityType
    }

    init(from decoder: Decoder) throws {
        let values = try? decoder.container(keyedBy: CodingKeys.self)

        if let value = try? values?.decode(GoalAchieved.self, forKey: .activityType) {
            self = .goalAchieved(value)
            return
        }

        if let value = try? values?.decode(NewGoal.self, forKey: .activityType) {
            self = .newGoal(value)
            return
        }

        throw DecodingError.decoding("Cannot Decode Activity")
    }
}

When using the Activity struct as my array I am getting DecodingError. However, when using ActivityType as my array it will decode fine but it will not give access to the documentID & createdAt. I cannot inherit Activity struct as it is non-protocol. How would I go about structuring this, please?

Upvotes: 1

Views: 881

Answers (1)

Daniel
Daniel

Reputation: 3597

This was kind of tricky and fun to figure out. We have three complications that make this tough:

  1. Variable coding keys
  2. Coding keys which we also want to keep as values
  3. Enumerated types with associated values

Here is my solution. It's a bit long. Let's start with your activity struct:

struct Activity {

    let documentId: String
    let createdAt: Int
    let activityType: ActivityType

}

Nice and easy. Now for that top-level decoding container:

struct Activities: Decodable {

    let activities: [Activity]

    init(from decoder: Decoder) throws {
        var activities: [Activity] = []

        let activitiesContainer = try decoder.container(keyedBy: CodingKeys.self)
        let container = try activitiesContainer.nestedContainer(keyedBy: VariableCodingKeys.self, forKey: .activities)
        for key in container.allKeys {
            let activityContainer = try container.nestedContainer(keyedBy: ActivityCodingKeys.self, forKey: key)
            let createdAt = try activityContainer.decode(Int.self, forKey: .createdAt)
            let activityType = try activityContainer.decode(ActivityType.self, forKey: .activityType)

            let activity = Activity(
                documentId: key.stringValue,
                createdAt: createdAt,
                activityType: activityType)

            activities.append(activity)
        }

        self.activities = activities
    }

    private enum CodingKeys: CodingKey {
        case activities
    }

    private struct VariableCodingKeys: CodingKey {

        var stringValue: String
        var intValue: Int?

        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        init?(intValue: Int) {
            return nil
        }

    }

    private enum ActivityCodingKeys: CodingKey {
        case createdAt, activityType
    }

}

You'll notice a couple interesting points:

  1. ActivityCodingKeys has only two of the fields in the Activity struct. That's because documentId is populated with the key of the nested container, which contains the rest of the data.
  2. We have VariableCodingKeys, which let us work with any key/documentId.

Finally, we have the ActivityType enum:

enum ActivityType: Decodable {

    case newGoal(String), achievedGoal(Double)

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let title = try? container.decode(String.self, forKey: .title) {
            self = .newGoal(title)
        } else if let percentage = try? container.decode(Double.self, forKey: .percentage) {
            self = .achievedGoal(percentage)
        } else {
            throw DecodingError.keyNotFound(
                CodingKeys.title,
                DecodingError.Context(
                    codingPath: decoder.codingPath,
                    debugDescription: "Expected title or percentage, but found neither."))
        }
    }

    private enum CodingKeys: CodingKey {
        case title, percentage
    }

}

One thing that surprised me as I was writing this up is that not all the CodingKeys have to be present for the decoder to generate the keyed container. I used that to combine title and percentage in one enum. Like your solution, I try decoding a certain key, see if it works, and move on if not.

I'll be the first to admit that this solution is not short. It does work, though, and it is kind of cool how it all works out. If you have any questions or ideas for making it more concise, let me know!

Upvotes: 2

Related Questions