Jack Morris
Making Enums Codable
Jan 12, 2020
Swift

Codable in Swift is super handy. Built in utilities like JSONEncoder and JSONDecoder, automatic conformance for structs/classes, and the ability to configure these auto-synthesised conformances, make dealing with encoded data in Swift a breeze.

Moving to/from encoded JSON is trivial:

struct User: Codable { // Codable conformance for free
  var name: String
}

let user = User(name: "Jack")
let encoder = JSONEncoder()
let data = try encoder.encode(user) // That's it! ๐ŸŒž

However, one thing that is missing is automatic Codable conformance for enums. You get this if your enum has raw values for each case, however this isn't always an option. Perhaps some of your cases have associated values?

Thankfully it's not too hard to add, albeit with the downside of some boilerplate.

Adding Conformance

Let's adjust User so that we can keep track of a user's favourite type of animal.

enum Animal: Codable { // Error!
  case dog
  case cat
}

struct User: Codable {
  var name: String
  var favouriteAnimal: Animal
}

As noted earlier, enums without raw values do not get automatic Codable conformance, so we need to add it ourselves.

Codable is actually a conjunction of two protocols: Encodable, and Decodable. They're each responsible for encoding / decoding the type, as you'd expect. Let's add this conformance now, before going over how it works.

extension Animal: Codable {
  private enum CodingKeys: CodingKey {
    case dog
    case cat
  }

  private struct InvalidDataError: Error {}

  /// For `Encodable` conformance.
  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    switch self {
    case .dog: try container.encode(true, forKey: .dog)
    case .cat: try container.encode(true, forKey: .cat)
    }
  }

  /// For `Decodable` conformance.
  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    if try container.decodeIfPresent(
      Bool.self, 
      forKey: .dog
    ) == true { // This is an optional, hence the funny `== true`.
      self = .dog
    } else if try container.decodeIfPresent(
      Bool.self, 
      forKey: .cat
    ) == true {
      self = .cat
    } else {
      throw InvalidDataError()
    } 
  }

}

We first define CodingKeys, an enum that conforms to the standard library CodingKey protocol. These are the “keys” that we want to encode our enum using in our final encoded representation. You can think of them as keys into a dictionary, where the dictionary is the encoded output.

Our strategy here is to have a single key per enum case; if that case is the case that's being encoded, we'll encode true against the respective key.

Animal.dog => {"dog":true}
Animal.cat => {"cat":true}

encode(to:) is the requirement for Encodable conformance, and implements this encoding. We construct a KeyedEncodingContainer for our CodingKeys, which lets us specify the values for each of our keys.

var container = encoder.container(keyedBy: CodingKeys.self)

With this container, we selectively encode true for the respective key: .dog, or .cat. Note that we don't need to encode values for all of the keys.

switch self {
case .dog: try container.encode(true, forKey: .dog)
case .cat: try container.encode(true, forKey: .cat)
}

Let's move on to init(from:), our initializer that we added for Decodable conformance. Here, we construct a KeyedDecodingContainer for our CodingKeys, which lets us extract the encoded values for each of our keys.

let container = try decoder.container(keyedBy: CodingKeys.self)

We then check for the existence of true for each of our keys using decodeIfPresent, and initialize ourselves once we've found the matching key for which true is encoded. We throw an error if none of them match.

That's it! Quite a bit of boilerplate, but the resulting code is very mechanical. If we were to use a JSONEncoder to test our conformance, the resulting JSON is also very readable.

let user = User(name: "Jack", favouriteAnimal: .dog)
let encoder = JSONEncoder()
let data = try! encoder.encode(user)
print(String(data: data, encoding: .utf8)!)
// {"name":"Jack","animal":{"dog":true}}

Associated Values

Users may also have animal preferences other than dogs or cats. Maybe we want to be able to handle all other options, so we add an additional .other case to our enum, where we can specify a custom animal name.

enum Animal: Codable {
  case dog
  case cat
  case other(String)
}

To extend our conformance to handle this new case, and its associated data, we first add another coding key, this time for the name of the animal encapsulated in the .other case.

private enum CodingKeys: CodingKey {
  case dog
  case cat
  case otherName  // Key for the name of our custom animal.
}

Then, rather than encoding / checking for a Bool for each case, for the .other case we encode the associated String directly.

func encode(to encoder: Encoder) throws {
  var container = encoder.container(keyedBy: CodingKeys.self)
  switch self {
  case .dog: 
    try container.encode(true, forKey: .dog)
  case .cat: 
    try container.encode(true, forKey: .cat)
  case .other(let name): 
    // Encode name directly as a String.
    try container.encode(name, forKey: .otherName)
  }
}

init(from decoder: Decoder) throws {
  let container = try decoder.container(keyedBy: CodingKeys.self)
  if try container.decodeIfPresent(
    Bool.self, 
    forKey: .dog
  ) == true {
    self = .dog
  } else if try container.decodeIfPresent(
    Bool.self, 
    forKey: .cat
  ) == true {
    self = .cat
  } else if let name = try container.decodeIfPresent(
    String.self,
    forKey: .otherName
  ) {
    // Decode the String directly into the .other case.
    self = .other(otherName)
  } else {
    throw InvalidDataError()
  } 
}

You can see the name encoded directly in the JSON now, for example.

let user = User(name: "Jack", favouriteAnimal: .other("๐Ÿฆ’"))
let encoder = JSONEncoder()
let data = try! encoder.encode(user)
print(String(data: data, encoding: .utf8)!)
// {"name":"Jack","animal":{"otherName":"๐Ÿฆ’"}}

If you do go with this strategy, I'd recommend writing some simple tests to encode/decode values for each of your cases. The implementation is very mechanical, but it's also very easy to mix up a coding key whilst encoding or decoding. Unit tests would catch this straight away. ๐Ÿงช

~

Thanks for reading! I'd love to hear your feedback: I'm @AnotherJackM on Twitter, or you can contact me directly.