Handling new Enum cases in Swift. UnknownCaseDecodable protocol

Vlad Sharaev
5 min readNov 26, 2023

--

Photo by Towfiqu barbhuiya on Unsplash

Introduction

Working with data that we decode as an enumeration always creeped me out due to possibility of new cases or changing the names of cases. I found a solution to use a safe case, that’s intended to be used as the default case if something comes up. I decided to develop this idea as much as I could.

Hence, in this article you’ll find what problems I’ve encountered, what ways I’ve found to overcome them and what the UnknownCaseDecodable protocol is.

Input Data

Imagine that we have a task board, and it has 3 main sections: new, in progress, done. Each task has a title and a priority level: low/medium/high. The list of tasks we get from an API using JSON:

[
{
"title": "Task #1",
"status": "new",
"priorityLevel": 50
}
]

Here we have an array of one element in which we have all the information needed to display it on the board.

Let’s create a Swift model for this task.

struct Task: Decodable {
let title: String
let status: Status
let priorityLevel: PriorityLevel
}

enum Status: String, Decodable {
case new, inProgress, done
}

enum PriorityLevel: Int, Decodable {
case low = 25
case medium = 50
case high = 75
}

Having this model, we can finally decode our JSON.

do {
let jsonURL = Bundle.main.url(forResource: "Tasks", withExtension: "json")!
let (data, _) = try await URLSession.shared.data(from: jsonURL)
let tasks = try JSONDecoder().decode([Task].self, from: data)

print(tasks)
} catch {
print(error)
}

We get a result which is successful:

[Task(title: "Task #1", status: Status.new, priorityLevel: PriorityLevel.medium)]

Issue #1: New cases

Our previous implementation was successful and it was published so that users could use these changes. Now we can move forward and expand our functionality.

Let’s say we need to add a new status - in review and a new priority level - highest. Let’s add these cases to our JSON.

[
{
"title": "Task #1",
"status": "inReview", <- changed
"priorityLevel": 100 <- changed
}
]

How will this affect users who have had previous changes? Let’s see.

dataCorrupted(
Swift.DecodingError.Context(
codingPath: [_JSONKey(stringValue: "Index 0", intValue: 0),
CodingKeys(stringValue: "status", intValue: nil)],
debugDescription: "Cannot initialize Status from invalid String
value inReview", underlyingError: nil))

It simply cannot decode the data, because the previous model doesn’t have these cases. There are a few ways how to avoid this problem:

  1. Force users to update their app when we make changes.
  2. Introduce new enums containing new cases.
  3. Add a safe case in advance. Let’s call this case as unknown.

Solution to the Issue #1: Adding an unknown case

Let’s focus on the 3d solution method. First of all, let’s add an unknown case and run the app.

enum Status: String, Decodable {
case new, inProgress, done
case unknown // a safe case
}

enum PriorityLevel: Int, Decodable {
case low = 25
case medium = 50
case high = 75
case unknown // a safe case
}
dataCorrupted(
Swift.DecodingError.Context(
codingPath: [_JSONKey(stringValue: "Index 0",
intValue: 0),
CodingKeys(stringValue: "status",
intValue: nil)],
debugDescription: "Cannot initialize Status from invalid String value inReview",
underlyingError: nil))

We’re still getting the same error. So adding an unknown case doesn’t solve our problem. We need somehow enhance our code in order to make the unknown case work as we intended.

We need to write new initializers from decoder.

extension Status {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
self = .init(rawValue: rawValue) ?? .unknown // magic is here
}
}

extension PriorityLevel {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(Int.self)
self = .init(rawValue: rawValue) ?? .unknown // magic is here
}
}

We implemented a behaviour that returns an unknown case every time when the initializer via rawValue returns nil. Let’s run the app.

[Task(title: "Task #1", 
status: Status.unknown,
priorityLevel: PriorityLevel.unknown)]

Everything works as expected! Now we can get data from our JSON. We just need to handle unknown cases appropriately.

Issue #2: Initializers

Every time when we need to implement an unknown case, we have to implement an initializer via decoder. This code is going to be copy-pasted again and again for each enumeration. This is definitely not we want to do.

Solution to the Issue #2: Adding an UnknownCaseDecodable protocol

Let’s implement this protocol.

protocol UnknownCaseDecodable: Decodable where Self: RawRepresentable {
associatedtype DecodeType: Decodable where DecodeType == RawValue

static var unknown: Self { get }
}

extension UnknownCaseDecodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(DecodeType.self)
self = .init(rawValue: rawValue) ?? Self.unknown
}
}
  1. Our protocol conforms to the protocol Decodable.
  2. Our protocol is intended only for enumerations, because Self: RawRepresentable.
  3. Our protocol is generic. Thus, both of our enumerations can implement this protocol.
  4. Our protocol has static variable unknown, since Swift 5.3 enums can use protocol’s static variables as cases: source.
  5. Our protocol has a default initializer implementation.

Let’s conform our enums to this protocol.

enum Status: String, UnknownCaseDecodable {
typealias DecodeType = String

case new, inProgress, done
case unknown
}

enum PriorityLevel: Int, UnknownCaseDecodable {
typealias DecodeType = Int

case low = 25
case medium = 50
case high = 75
case unknown
}

It looks good, but not perfect. Since our protocol uses an associatedtype, we need to specify the type. Fortunately, we can do this indirectly.

protocol UnknownCaseDecodable: Decodable where Self: RawRepresentable {
associatedtype DecodeType: Decodable where DecodeType == RawValue

static var unknown: Self { get }
var rawValue: DecodeType { get } // specifies the type of DecodeType
}

As far as our enums have rawValue, we don’t need to directly specify the type to conform UnknownCaseDecodable.

Update - 03.12.2023

A fellow iOS Engineer, Tuan Hoang (Eric), brought a shortened version of the idea:

protocol UnknownCaseDecodable: Decodable where Self: RawRepresentable {
static var unknown: Self { get }
}

extension UnknownCaseDecodable where RawValue: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(RawValue.self)
self = .init(rawValue: rawValue) ?? Self.unknown
}
}

The improvements are:

  • We don’t need to create the associatedtype DecodeType in order to use it as a Decodable type instead of RawValue.
  • We don’t need to declare the rawValue: DecodeType property in order to make RawValue a Decodable type.

Instead, we just make an extension for our initializer where RawValue is Decodable. Pretty tricky and profound approach.

Conclusion

  1. We found out what problems we may encounter using Decodable enumerations.
  2. We found out how to solve these problems.

Final code looks like this:

protocol UnknownCaseDecodable: Decodable where Self: RawRepresentable {
static var unknown: Self { get }
}

extension UnknownCaseDecodable where RawValue: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(RawValue.self)
self = .init(rawValue: rawValue) ?? Self.unknown
}
}

enum Status: String, UnknownCaseDecodable {
case new, inProgress, done
case unknown
}

enum PriorityLevel: Int, UnknownCaseDecodable {
case low = 25
case medium = 50
case high = 75
case unknown
}
Then / Now

Thanks for reading. I hope this was helpful. If you have any thoughts about this article, please, leave a comment.

--

--