Enums and JSON decoders

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' case

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)' case

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")
  }
}