Codable conformance for Swift enums with associated values
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)
}
# 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 thecodingPath
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.