By Will Braynen
In this blog post, I will show how to have your cake and eat it too: how to use an enum in your model and yet avoid having your JSON decoder choke on unexpected values in a json. And no, I do not think the new “@unknown” keyword in Swift 5 is helpful. But luckily, the Swift 4 solution also works in Swift 5.
1. Simple enum
It’s true: Marshall Islands, Liechtenstein and San Marino are some of the smallest countries on earth. Enumerating tiny countries, the simpler way to write an enum in Swift 4 and Swift 5, is:
/// A model that corresponds to the endpoint's JSON. struct SimpleModel: Codable { let location: TinyCountry } /// Enumeration used by the model above. enum TinyCountry: String, Codable { case liechtenstein = "Liechtenstein" case marshallIslands = "Marshall Islands" case sanMarino = "San Marino" }
But what about Tuvalu? It is also a tiny country. What happens when your location service unexpectedly returns “Tuvalu” in the JSON? Or, more to the point, what happens when the services responds with a JSON that contains a value you did not anticipate? (One reason a service might violate its contract is, for example, if the service is a BFF that simply passes values to you from downstream services that it does not know much about or has adequate control over.)
A good way to prepare for departures from the happy path is to write an exhaustive enum, so that your decoder doesn’t choke on some unexpected value it might find in the json. This is done by adding a catch-all ‘other’ case to the enum and works in both Swift 4 and Swift 5.
2. exhaustive enum: ‘other’ case
This works in both Swift 4 and in Swift 5:
/// A model that corresponds to the endpoint's JSON. struct BetterModelOne: Codable { let location: TinyCountry } /// Enumeration used by the model above. enum TinyCountry: String, Codable { case liechtenstein = "Liechtenstein" case marshallIslands = "Marshall Islands" case sanMarino = "San Marino" case other init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) self = TinyCountry(rawValue: value) ?? .other } }
Even though it was not chosen as the correct answer, this was in fact the solution given back in August of 2018 on stackoverflow by Stéphane Copin.
3. exhaustive enum: ‘other(string)’ case
If you want to remember what the unexpected value was, then you could use an other(String)
case instead of an other
case. A solution along these lines might look as follows:
/// A model that corresponds to the endpoint's JSON. struct BetterModelTwo: Codable { let location: TinyCountry } /// Enum used by the model above. enum TinyCountry: RawRepresentable, Equatable, Codable { case liechtenstein case marshallIslands case sanMarino case other(String) public var rawValue: String { switch self { case .liechtenstein: return "Liechtenstein" case .marshallIslands: return "Marshall Islands" case .sanMarino: return "San Marino" case .other(let value): return value } } public init(rawValue: String) { switch rawValue { case "Liechtenstein": self = .liechtenstein case "Marshall Islands": self = .marshallIslands case "San Marino": self = .sanMarino default: self = .other(rawValue) } } }
4. The unhelpful exhaustive switch in Swift 5
The “@” syntax just won’t die; it’s like a zombie from the Walking Dead. Swift 5 introduced an @unknown keyword that you can place in front of default in your switch statement (as in @unknown default). Perhaps that can help avoid introducing breaking changes at the call site when adding a new case to your enum, which can be handy if your enum (along with the rest of the model) lives in a networking library.
I do not see, however, how that helps foolproof your json decoder from choking on json surprises. After all, the problem is more upstream: what we need to solve our choking problem is a catch-all enum case (like the ‘other’ case), not a catch-all default in the switch that handles the enum. The second is about handling an enum that has already been created, while the first is about instantiating an enum object in the first place.
But if I misunderstood this new Swift feature, let me know by leaving a comment at the bottom of the page!
2.1 Unit test with simple enum
To illustrate, the following unit test fails. It fails in the “When” step and never gets to the “Then” step because the json decoder chokes on the json:
@testable import MyApp import XCTest class DeserializationTests: XCTestCase { // Gerkin-style unit test func testThatDecoderCanUnmarshallAnObject() throws { // Given let jsonData = """ { "location": "Tuvalu" } """.data(using: String.Encoding.utf8)! // When let model = try JSONDecoder().decode(SimpleModel.self, from: jsonData) // Then XCTAssertEqual(model.location.rawValue, "Tuvalu") } }
The JSON Decoder tries to decode the JSON data but fails and the testThatDecoderCanUnmarshallAnObject
method throws
when try
has no luck.
In fact, the way this test is written, to say that it failed is misleading because it did not fail in the “Then” step. Instead, it failed in the “When” step and nearly crashed. Had we foregone throws
and instead forced the try with a try!
, our test would have crashed on the line with the decode
call. (Of course it’s true that we could rewrite this test using a “try-catch” clause and placed it in the “Then” step; but still.)
2.2 Unit test with 'other' case2>
This test, on the other hand, passes:
@testable import MyApp import XCTest class DeserializationTests: XCTestCase { func testThatDecoderCanUnmarshallAnObject() throws { // Given let jsonData = """ { "location": "Tuvalu" } """.data(using: String.Encoding.utf8)! // When let model = try JSONDecoder().decode(BetterModelOne.self, from: jsonData) // Then XCTAssertEqual(model.location, .other) } }
2.3 Unit test with 'other(String)' case2>
This test also passes:
@testable import MyApp import XCTest class DeserializationTests: XCTestCase { // Gherkin-style unit test func testThatDecoderCanUnmarshallAnObject() throws { // Given let jsonData = """ { "location": "Tuvalu" } """.data(using: String.Encoding.utf8)! // When let model = try JSONDecoder().decode(BetterModelTwo.self, from: jsonData) // Then XCTAssertEqual(model.location, .other("Tuvalu")) XCTAssertEqual(model.location.rawValue, "Tuvalu") } }