Codable conformance for Swift enums with associated values

Update: Swift 5.5 has added automatic Codable conformance for enums with associated values. See Codable synthesis for enums with associated values proposal for details.

In our recent app 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. We can encode it as JSON, save the data into UserDefaults and decode the JSON back into the enum on app launch.

# 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. 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
}

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
    }
}

We will make our enum conform to Encodable protocol that 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)
}
Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Check out our book!

Integrating SwiftUI into UIKit Apps

Integrating SwiftUI intoUIKit Apps

UPDATED FOR iOS 17!

A detailed guide on gradually adopting SwiftUI in UIKit projects.

  • Discover various ways to add SwiftUI views to existing UIKit projects
  • Use Xcode previews when designing and building UI
  • Update your UIKit apps with new features such as Swift Charts and Lock Screen widgets
  • Migrate larger parts of your apps to SwiftUI while reusing views and controllers built in UIKit

# 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, it's 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.

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