23 July 2019

📦 Codable Enums in Swift

I’ve been writing an app using ReSwift recently and one feature that I’ve found quite cool is the ability to archive the entire App’s state so it can be hydrated later. The most straightforward way to do this is to make your AppState conform the the Codeable protocol and save it to disk using the JSONEncoder.

There was one tricky situation that I had to work around. The app shows a list of items that are fetched from the network and these items are stored in the AppState. Because this list of items is fetched from the network (or from a remote) this list has four distinct states. The list could have not attempted to have been fetched yet (unintialized), it could be loading, it could be loaded (with our value), or it could have failed.

As there are four distinct states, this is a natural candidate to be modeled using a Swift Enum.

enum Loadable<T: Equatable, Codable>: Equatable, Codable {
    case initial
    case loading
    case value(T)
    case error
}

I’ve ignored the associated error value for now

We’ve added the Codable protocol as we’d like to be able to encode and decode this value, although unfortunately the compiler complains about this…

screenshot

There’s the one issue when trying to make this enum conform to Codeable, and it’s that associated variable in the .value case. Swift doesn’t automatically do the work for us to generate the functions required for conformance to the Codable protocol. We have to do it ourselves.

There is a proposal on the Swift Forums pitching to add this to the language, but as it’s not yet been implemented we’ll borrow the implementation suggestion from the forum post for the time being.

This proposes a possible way to add automatic conformance for Codable Enums that have associated variables.

extension Loadable: Codable {

    enum Discriminator: String, Codable, CodingKey {
        case initial
        case loading
        case value
        case error
    }

    enum CodingKeys: String, CodingKey {
        case discriminator
        case value_value // 😅
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        switch self {
        case .initial:
            try container.encode(Discriminator.initial, forKey: .discriminator)
        case .loading:
            try container.encode(Discriminator.loading, forKey: .discriminator)
        case .value(let value):
            try container.encode(Discriminator.value, forKey: .discriminator)
            try container.encode(value, forKey: .value_value)
        case .error:
            try container.encode(Discriminator.error, forKey: .discriminator)
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let discriminator = try container.decode(Discriminator.self, forKey: CodingKeys.discriminator)

        switch discriminator {
        case .initial:
            self = .initial
        case .loading:
            self = .loading
        case .value:
            let value = try container.decode(T.self, forKey: .value_value)
            self = .value(value)
        case .error:
            self = .error
        }
    }
}

Once adding this, the compiler errors will be fixed and we can encode and decode our state!

Enums cases with multiple associated variables

Essentially what we’re doing is breaking up the enum into the case type, the discriminator, and any associated variables. If there are more than one associated variables per case then we define the CodingKeys to include all possible associated variables.

Let’s take the example above and let’s add the date the load started, and the loading source - I couldn’t think of something better while writing this 🤷‍♂️ - to the .loading case in our Loadable enum.

enum Loadable<T: Equatable, Codable>: Equatable, Codable {
    case initial
    case loading(startDate: Date, source: String)    case value(T)
    case error
}

We’d add a the extra cases to the CodingKeys enum, as well as the decoding and encoding:

extension Loadable: Codable {

    enum Discriminator: String, Codable, CodingKey {
        case initial
        case loading
        case value
        case error
    }

    enum CodingKeys: String, CodingKey {
        case discriminator
        case value_value
        case loading_start_date        case loading_source    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        switch self {
        case .initial:
            try container.encode(Discriminator.initial, forKey: .discriminator)
        case .loading(let startDate, let source):            try container.encode(Discriminator.loading, forKey: .discriminator)            try container.encode(startDate, forKey: .loading_start_date)            try container.encode(source, forKey: .loading_source)        case .value(let value):
            try container.encode(Discriminator.value, forKey: .discriminator)
            try container.encode(value, forKey: .value_value)
        case .error:
            try container.encode(Discriminator.error, forKey: .discriminator)
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let discriminator = try container.decode(Discriminator.self, forKey: CodingKeys.discriminator)

        switch discriminator {
        case .initial:
            self = .initial
        case .loading:
            let startDate = try container.decode(Date.self, forKey: .loading_start_date)            let source = try container.decode(String.self, forKey: .loading_source)            self = .loading(startDate, source)        case .value:
            let value = try container.decode(T.self, forKey: .value_value)
            self = .value(value)
        case .error:
            self = .error
        }
    }
}

And there we are! This will be a welcome addition to the Swift language so it could auto synthesize this for us. I’m not sure how it will handle migrations - to do that we’ll likely have to write our own implementation like we just have.

Hope this helps!

Will Townsend

Hey 👋 I'm Will Townsend, I hope you enjoyed this post. If you have any questions you can contact me on Mastodon and maybe Twitter, cheers!