Safely decode Swift enumerations

The JSON format often serves as data exchange format between our iOS applications and server side APIs. For instance, an iOS application makes an HTTP request and a server replies with a JSON payload in the response. To convert the JSON payload into model objects, the model types first have to conform to Decodable and then the payload is decoded using a JSONDecoder. Everything works well enough until we have to decode an enumeration.

The problem with decoding enumerations lies within the fact that an enumeration's value in JSON is often encoded as a simple literal (e.g. a string or an integer). The set of all the possible literals that we can potentially have in JSON is almost unlimited. However in the Swift world, the set of valid values of an enumeration is limited by the cases declared when defining the enumeration. In real-world applications, server side errors happen and we sometimes receive malformed literals, empty strings or null values in lieu of valid enumeration values.

In this article, we'll explore how to handle the unpredictability of an enumeration's value on the server side in the predictable world of Swift's enumerations.

The problem

Let's assume that we're building a payment history screen in an application. When that screen launches, we retrieve an array of JSON objects representing the list of payments a user has performed for a service and present that list in a table view. We have a type Payment and among other things, a payment has an id, an amount and a type. The type can be credit-card, debit-card, cash or bank-account. Naturally, we model the type as an enumeration.

struct Payment: Decodable {
    let id: String
    let amount: Double
    let type: Type

    enum Type: String, Decodable {
        case creditCard = "credit-card"
        case debitCard = "debit-card"
        case cash
        case bankAccount = "bank-account"
    }
}

Now let's say we receive the following JSON seemingly containing all valid Payment objects but one.

[
    {
        "id": "fxndavtw==",
        "amount": 50.0,
        "type": "bank-account"
    },
    {
        "id": "kmZrlutz==",
        "amount": 150.0,
        "type": "credit_card"
    },
    {
        "id": "imPrnafz==",
        "amount": 75.0,
        "type": "credit-card"
    },
    ...
]

The decoding of this JSON array will fail because the second Payment object has a wrong type, credit_card instead of credit-card. If the JSON decoding instructions are wrapped inside a try/catch, our payment screen would end up in an unexpected state, probably displaying an empty table view. And if there is no try/catch, our whole app will crash while trying to render the JSON.

One single malformed Payment object made the payment screen unusable or worse, made the whole app crash. There are valid situations where we need to crash if we encounter such errors. For instances where we should not crash (like in our payment history screen), we need to somehow replace the malformed value with a fallback value or ignore the malformed objects in the payload.

A common solution is to make the enumeration optional and to manually implement the Decodable conformance.

struct Payment {
    let id: String
    let amount: Double
    let type: Type?
    
    enum Type: String, Decodable {
        case creditCard = "credit-card"
        case debitCard = "debit-card"
        case cash
        case bankAccount = "bank-account"
    }
}

extension Payment: Decodable {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(String.self)
        self.amount = try container.decode(Double.self)
        
        // We use try? to decode the payment type
        // and we assign `nil` if the decoding fails.
        self.type = try? container.decode(Type.self)
    }
}

While technically correct, this solution presents 2 issues.

  1. It is semantically redundant to make an enumeration optional. Because an optional is also an enumeration, marking an enumeration as optional is akin to wrapping it into another. It would be more straightforward to just add a case none to the original enumeration. Soroush Khanlou wrote a nice article expanding on this, Enums And Optionals.
  2. This pattern would have to be repeated for every other enumeration we subsequently add in Payment and potentially for many other enumerations in the code base.

Fortunately, there is a better and cleaner solution.

A solution

Our solution will consist in attaching a fallback value to a decodable enumeration. That fallback value will be used as the value of the enumeration whenever the decoding fails.

Let's start by introducing a protocol requiring conforming types to provide a fallback value.

protocol SafeDecodable: Decodable {
    static var fallback: Self { get }
}

Next, since we want to use this protocol mostly in conjunction with enumerations, it might be a good idea to make it extend RawRepresentable. The compiler automatically adds a RawRepresentable conformance to any enumeration with a string, integer or floating point raw value. In addition, any enumeration that needs to be JSON-decoded will have to have an associated raw value.

protocol SafeDecodable: RawRepresentable, Decodable {
    static var fallback: Self { get }
}

Finally, we add a default implementation of init(from: Decoder) that will try to decode the RawValue of the RawRepresentable. If the decoding succeeds, it will try to create the RawRepresentable using this initializer. If there is any failure at any point, it will simply assign fallback to self.

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

Et voilà! To use this solution in our previous example, we only need to replace Decodable by SafeDecodable in the definition of Type and to provide a fallback value.

struct Payment: Decodable {
    let id: String
    let amount: Double
    let type: Type

    enum Type: String, SafeDecodable {
        case creditCard = "credit-card"
        case debitCard = "debit-card"
        case cash
        case bankAccount = "bank-account"
        case ignore

        static let fallback: Type = .ignore
    }
}

Now, in our payment history screen, we just need to discard the payments with a type .ignore before displaying them in the table view.

let payments = try JSONDecoder()
    .decode([Payment].self)
    .filter { $0.type != .ignore }

// Renders a list of payments on the screen.
render(payments: payments)

Another option would be to set the fallback to an .unknown case and handle that case on the view model layer appropriately.

Conclusion

In situations where we should recover when the decoding of an enumeration fails because of a malformed or wrong raw value, our solution above works pretty well. It can be used to set valid default values when malformed values are encountered or to replace them with sentinel values that can be filtered out a later time. In addition, this solution is opt-in; we only use it where it makes sense to.

Thanks for reading 👋.