VButko

How to customize Enum Decoding in Swift with code samples

Enums are incredibly useful in our data models in Swift, making decoding them from network responses a common task. While simply conforming to Codable usually gets the job done, there are instances where we may want to customize the default decoding process. This article explores common techniques to tailor enum decoding to your specific needs.

Practical Use Cases

You may find the need for custom decoding logic when dealing with varying backend values or when multiple incoming values map to a single enum case. For example, making decoding case-insensitive or handling extra values that aren't needed in the app. Thankfully, Swift offers straightforward ways to solve these problems.

To illustrate these concepts, consider the following OrderStatus enum:

enum OrderStatus: String, CaseIterable, Codable {
    case inProgress = "In Progress"
    case readyForPickup = "Ready for Pick Up"
    case shipped = "Shipped"
    case completed = "Completed"
    case cancelled = "Cancelled"
    case unknown
}

Implement Case-Insensitive Decoding Logic

By default, the Decodable protocol is case-sensitive. However, you can implement case-insensitive decoding by creating a custom init(from decoder: Decoder) method.

extension OrderStatus {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let decodedValue = try container.decode(String.self)
        
        if let orderStatus = OrderStatus.allCases.first(where: { $0.rawValue.lowercased() == decodedValue.lowercased() }) {
            self = orderStatus
        } else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid Order Status value")
        }
    }
}

Note: You could opt for a fallback unknown value instead of throwing an error, but using nil-coalescing at the call site could be a more versatile approach.

Combine Multiple Values in Decoder

Another typical scenario involves mapping multiple decoded values to a single enum case that's suitable for your app.

First, let's list the extra raw values we may get:

extension OrderStatus {
    private var extraRawValues: [String] {
        switch self {
        case .inProgress:
            return ["New", "In Process", "In Transit"]
        case .cancelled:
            return ["Cancellation in process"]
        case .completed:
            return ["Delivered"]
        case .readyForPickup,
                .shipped,
                .delivered,
                .unknown:
            return []
        }
    }
}

Tip: Avoiding the use of a default case can serve as a reminder to revisit and update any associated logic whenever you add a new case to your enum.

Next, update the initializer to handle these extra raw values:

extension OrderStatus {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let decodedValue = try container.decode(String.self)
        
        if let orderStatus = OrderStatus.allCases.first(where: { orderStatus in
            // Combine all possible values we accept for the order status
            let possibleValues = [orderStatus.rawValue.lowercased()] + orderStatus.extraRawValues.map { $0.lowercased() }
            return possibleValues.contains { $0 == decodedValue.lowercased() }
        }) {
            self = orderStatus
        } else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid Order Status value")
        }
    }
}

With this setup, our decoding logic can now intelligently merge various input values into the enum cases we actually use within the app. Plus, it's capable of handling variations in letter casing.

Modify rawValue Initializer for More Flexibility

While the previous method is effective, there's an alternative that offers more flexibility and readability. The built-in Decodable protocol relies on the raw value initializer for enums conforming to RawRepresentable. Since our enum is associated with a String value, we have the option to modify the init?(rawValue: String) initializer for added versatility. This approach enables us to reuse the logic in case we need to use the initializer directly.

extension OrderStatus {
    init?(rawValue: String) {
        if let orderStatus = OrderStatus.allCases.first(where: { orderStatus in
            // Combine all possible values we accept for the order status
            let possibleValues = [orderStatus.rawValue.lowercased()] + orderStatus.extraRawValues.map { $0.lowercased() }
            return possibleValues.contains { $0 == rawValue.lowercased() }
        }) {
            self = orderStatus
        } else {
            return nil
        }
    }
}

Unit Tests

To ensure that our custom decoding logic works as expected, it's a good idea to add some unit tests. Simple tests like these can help prevent unintended changes to the logic.

final class OrderStatusTests: XCTestCase {
    func testDecodingInitializer() throws {
        try verifyDecodingInitializer(stringValue: "In Progress", expectedStatus: .inProgress)
        try verifyDecodingInitializer(stringValue: "In progress", expectedStatus: .inProgress)
        try verifyDecodingInitializer(stringValue: "New", expectedStatus: .inProgress)
        try verifyDecodingInitializer(stringValue: "in process", expectedStatus: .inProgress)
        try verifyDecodingInitializer(stringValue: "IN TRANSIT", expectedStatus: .inProgress)
        try verifyDecodingInitializer(stringValue: "Cancellation in process", expectedStatus: .cancelled)
        try verifyDecodingInitializer(stringValue: "Delivered", expectedStatus: .completed)
    }
    
    private func verifyDecodingInitializer(stringValue: String, expectedStatus: OrderStatus, file: StaticString = #file, line: UInt = #line) throws {
        // GIVEN
        let jsonString = "\"\(stringValue)\""
        let data = try XCTUnwrap(jsonString.data(using: .utf8))

        // WHEN
        let decodedStatus = try JSONDecoder().decode(OrderStatus.self, from: data)

        // THEN
        XCTAssertEqual(expectedStatus, decodedStatus, file: file, line: line)
    }
}

Tip: Utilizing verification methods like this one is highly beneficial. They allow for targeted, reusable tests that can be applied in various scenarios.

Conclusion

Swift's default Decodable implementation for enums serves well for most cases. However, when dealing with edge cases or special requirements, custom decoding logic becomes invaluable. This article showed you how to make decoding case-insensitive and how to combine multiple incoming values into a single enum case, all while ensuring reliability through unit tests.

I hope you enjoyed this article. If you have any questions, suggestions, or feedback, please let me know on Twitter.

Thanks for reading!