How to save Swift Enum Types with Associated Values

lukabratos

Luka Bratos

Posted on September 8, 2019

How to save Swift Enum Types with Associated Values

In this blog post we will explore how to create enumerations with associated values, make them conform to the Codable protocol by implementing Decoder and Encoder, and persist them to the file.

Motivation

In one of the SDKs that I’ve been recently working on we wanted to provide functionality where we could persist all of the network operations to the persistent data store in case our customers device lost network connectivity.
We wanted to be able to retrieve operations later in time from the client in the same order that they’d been enqueued in, and execute every single one of them when the user’s device reconnects to the internet - we wanted to implement a queue. Because we have been using enum with associated values to describe operations in a type-safe way I’ve decided to explore further how could I implement all of the requirements.

Associated values

Associated values in Swift let us store additional data of any type to enum case values.

Lets say that we have encoded some operations related to the user profile update in an enum:

enum UserProfileOperations {
    case updateProfilePicture(URL)
    case updateDateOfBirth(Int)
}

Constants or variables of UserProfileOperations enumeration can either store a value of updateProfilePicture with an associated value of type URL or a value updateDateOfBirth with an associated value of type Int.

This example creates a new variable called profilePictureOperation and assigns it a value of UserProfileOperations.updateProfilePicture with associated value URL(string: "https://via.placeholder.com/150/1ee8a4"):

let profilePictureOperation = UserProfileOperations.updateProfilePicture(URL(string: "https://via.placeholder.com/150/1ee8a4")!)

Similar example for dateOfBirthOperation variable where we assign UNIX timestamp (associated value of type Int) to the value UserProfileOperations.updateDateOfBirth:

let dateOfBirthOperation = UserProfileOperations.updateDateOfBirth(1561825023)

Adding Codable conformance

Since Swift 4.1 cannot automatically synthesize conformance to the Codable if we’re using enum with one or more associated values we have to do that manually:

We will start by creating an extension of UserProfileOperations enum that conforms to the Codable protocol:

extension UserProfileOperations: Codable {
    ...
}

Next, we will define coding keys, which will be used as the authoritative list of properties that must be included when instances of a codable type are encoded or decoded:

extension UserProfileOperations: Codable {
    enum CodingKeys: CodingKey {
        case profilePictureURL
        case dobTimestamp
    }
}

If keys in our JSON payload differ from the ones that we have defined in CodingKeys we’ll receive an error.

Because Codable is a type alias for the Encodable and Decodable protocols we need to make sure that our enum conforms to both. Lets start with implementation of the required method for Decodable: init(from:):

extension UserProfileOperations: Codable {
    ...

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // Decode the value of `profilePictureURL` from the decoder.
        if let profilePictureURL = try? container.decode(URL.self, forKey: .profilePictureURL) {
            self = .updateProfilePicture(profilePictureURL)
            return
        }
        if let dob = try? container.decode(Int.self, forKey: .dobTimestamp) {
            self = .updateDateOfBirth(dob)
            return
        }
        // If the keys used in serialized data format don't match with ones specified
        // in `CodingKeys`, throw an error.
        throw CodingError.message("Error: \(dump(container))")
    }
}

Lastly, we need to provide implementation for Encodable, which is method encode(to:):

extension UserProfileOperations: Codable {
    ...

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        switch self {
        case .updateProfilePicture(let url):
            try container.encode(url, forKey: .profilePictureURL)
        case .updateDateOfBirth(let dob):
            try container.encode(dob, forKey: .dobTimestamp)
        }
    }
}

Encoding and Decoding

We can encode profilePictureOperation by using JSONEncoder:

let encoder = JSONEncoder()
let profilePictureOperation = UserProfileOperations.updateProfilePicture(URL(string: "https://via.placeholder.com/150/1ee8a4")!)
let data = try! encoder.encode(profilePictureOperation)
let jsonString = String(data: data, encoding: .utf8)!
print(jsonString) // {"profilePictureURL":"https:\/\/via.placeholder.com\/150\/1ee8a4"}

And do the reverse, by using JSONDecoder:

let decoder = JSONDecoder()
let profilePictureJSONString = """
{"profilePictureURL":"https://via.placeholder.com/150/1ee8a4"}
"""
let profilePictureOperation = try! decoder.decode(UserProfileOperations.self, from: profilePictureJSONString.data(using: .utf8)!)

Persisting values atomically to the file

Lets create a few instances of our UserProfileOperations enum and add them into array:

let johnProfilePictureURL = UserProfileOperations.updateProfilePicture(URL(string: "https://via.placeholder.com/150/197d29")!)
let johnDateOfBirthOperation = UserProfileOperations.updateDateOfBirth(1561825023)

let kateProfilePictureURL = UserProfileOperations.updateProfilePicture(URL(string: "https://via.placeholder.com/150/c70a4d")!)
let kateDateOfBirthOperation = UserProfileOperations.updateDateOfBirth(733622400)

let timProfilePictureURL = UserProfileOperations.updateProfilePicture(URL(string: "https://via.placeholder.com/150/56acb2")!)
let timDateOfBirthOperation = UserProfileOperations.updateDateOfBirth(586915200)

let operations = [johnProfilePictureURL, johnDateOfBirthOperation, kateProfilePictureURL, kateDateOfBirthOperation, timProfilePictureURL, timDateOfBirthOperation]

In the next step we will create a JSONEncoder and use it to encode our operations array into the data object which will contain encoded JSON data.

let jsonEncoder = JSONEncoder()
let data = jsonEncoder.encode(operations)

Finally, we can persist this information into the file:

let url = URL(fileURLWithPath: "operations")
data.write(to: url, options: .atomic)

Reading values from the file

let operationsData = try! Data(contentsOf: url)
let jsonDecoder = JSONDecoder()
let operationsArray = try! jsonDecoder.decode([UserProfileOperations].self, from: operationsData)

Conclusion

In this blog post we have looked into associated values and implemented required methods to conform to the Codable protocol. We have then encoded and decoded values by using JSONEncoder and JSONDecoder.
Finally, we have saved the data into the file so it can be retrieved and deserialized back when needed.

I hope you enjoyed this post. Questions? Feedback? Hit me up on Twitter!

You can download the Swift Playgrounds file here.

💖 💪 🙅 🚩
lukabratos
Luka Bratos

Posted on September 8, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related