Codable Conformance for Swift Enums with Multiple Associated Values of Different Types


In our recent app Cleora we have a global state represented by an enum which works great with SwiftUI reactive style. We wanted to persist the most recent state of the app in UserDefaults so that when the app is restarted we can restore the previous state.


To achieve that we needed to make our state enum conform to Codable. Then we can encode it as JSON, save the data into UserDefaults and decode the JSON back into the enum on app launch.


You can find the code for the article on GitHub.



Example Enum with Associated Values


The example we will consider in this article is similar to our state enum. It has cases with and without associated values, and the associated values are of different types.


enum ViewState {
    case empty
    case editing(subview: EditSubview)
    case exchangeHistory(connection: Connection?)
    case list(selectedId: UUID, expandedItems: [Item])
}


As of Swift 5 only enums without associated values have automatic conformance to Codable. For example, to make our EditSubview conform to Codable we only need to indicate the conformance in the declaration.


enum EditSubview: Codable {
    case headers
    case query
    case body
}


There is a discussion on adding Automatic Codable Conformance For Enums with Associated Values, but at the moment we have to do it manually.


Codable is just a typealias for Encodable & Decodable protocols. First, we are going to make our state enum conform to Encodable, so that we can save it to disk.



Encoding


There are multiple approaches on how to encode enums into JSON, we chose one that uses the case name as a key. We will add a nested enum CodingKeys inside our state enum which will have all the possible top level keys that the JSON can have.


extension ViewState {
    enum CodingKeys: CodingKey {
        case empty, editing, exchangeHistory, list
    }
}


Then we will make our enum conform to Encodable. Encodable protocol has one required method encode(to encoder: Encoder). Inside this method first we get the KeyedEncodingContainer from the encoder. Then switch on self and encode the current case.


extension ViewState: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        switch self {
        case .empty:
            try container.encode(true, forKey: .empty)
        case .editing(let subview):
            try container.encode(subview, forKey: .editing)
        case .exchangeHistory(let connection):
            try container.encode(connection, forKey: .exchangeHistory)
        case .list(let selectedID, let expandedItems):
            var nestedContainer = container.nestedUnkeyedContainer(forKey: .list)
            try nestedContainer.encode(selectedID)
            try nestedContainer.encode(expandedItems)
        }
    }
}


  • If the case doesn't have any associated values, we will encode true as its value to keep the JSON structure consistent.
  • For cases with one associated value that itself conforms to Codable, we will encode this value using the current case as a key.
  • For cases with multiple associated values, we will encode the values inside a nestedUnkeyedContainer maintaining their order.


To check if our encoding worked we can create sample data and print the JSON string.


let encoder = JSONEncoder()

let viewState = ViewState.list(
    selectedId: UUID(),
    expandedItems: [Item(name: "Request 1"), Item(name: "Request 2")]
)

do {
    let encodedViewState = try encoder.encode(viewState)
    let encodedViewStateString = String(data: encodedViewState, encoding: .utf8)
    print(encodedViewStateString ?? "Wrong data")
} catch {
    print(error.localizedDescription)
}



Decoding


To make our enum conform to Decodable protocol we have to implement init(from decoder: Decoder). We get the KeyedDecodingContainer from the decoder and extract its first key. As we encoded an enum, we only expect to have one top level key. Then we switch on the key to initialize the enum.


init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    let key = container.allKeys.first
    
    switch key {
    case .empty:
        self = .empty
    case .editing:
        let subview = try container.decode(
            EditSubview.self,
            forKey: .editing
        )
        self = .editing(subview: subview)
    case .exchangeHistory:
        let connection = try container.decode(
            Connection?.self,
            forKey: .exchangeHistory
        )
        self = .exchangeHistory(connection: connection)
    case .list:
        var nestedContainer = try container.nestedUnkeyedContainer(forKey: .list)
        let selectedId = try nestedContainer.decode(UUID.self)
        let expandedItems = try nestedContainer.decode([Item].self)
        self = .list(
            selectedId: selectedId,
            expandedItems: expandedItems
        )
    default:
        throw DecodingError.dataCorrupted(
            DecodingError.Context(
                codingPath: container.codingPath,
                debugDescription: "Unabled to decode enum."
            )
        )
    }
}


  • For cases without associated values, we don't need to decode anything, only assign the case to self.
  • For cases with one associated value, we try to decode that value.
  • For cases with multiple associated values, we need to get the nestedUnkeyedContainer from the decoder, then decode the values one by one in the correct order.
  • If we hit the default case, we will throw a DecodingError including the codingPath for debugging.


To test the decoding we can try to recreate our enum from the encodedViewState data we got earlier.


let decoder = JSONDecoder()

do {
   let decodedViewState = try decoder.decode(ViewState.self, from: encodedViewState)
} catch {
    print(error.localizedDescription)
}



Helper functions for multiple associated values


If you have a lot of cases with multiple associated values, then encoding and decoding them one by one might get a bit long. To solve it, its possible to define extensions on KeyedEncodingContainer and KeyedDecodingContainer that can take multiple values as parameters and encode them into a nestedUnkeyedContainer.


extension KeyedEncodingContainer {
    mutating func encodeValues<V1: Encodable, V2: Encodable>(
        _ v1: V1,
        _ v2: V2,
        for key: Key) throws {

        var container = self.nestedUnkeyedContainer(forKey: key)
        try container.encode(v1)
        try container.encode(v2)
    }
}

extension KeyedDecodingContainer {
    func decodeValues<V1: Decodable, V2: Decodable>(
        for key: Key) throws -> (V1, V2) {

        var container = try self.nestedUnkeyedContainer(forKey: key)
        return (
            try container.decode(V1.self),
            try container.decode(V2.self)
        )
    }
}


Using these extensions we can rewrite our encoding and decoding for .list case which has two associated values in much fewer lines of code.


case .list(let selectedID, let expandedItems):
    try container.encodeValues(selectedID, expandedItems, for: .list)


case .list:
    let (selectedId, expandedItems): (UUID, [Item]) = try container.decodeValues(for: .list)
    self = .list(selectedId: selectedId, expandedItems: expandedItems)


You can write more functions in the extensions that would take more parameters depending on your needs. There are multiple proposals in the works that would let Swift handle this list of generic types more gracefully.


Feel free to get the code for this article from GitHub and use it in your projects.