Splitting a JSON object into an enum and an associated object with Codable

Published on: April 5, 2021

Decoding data, like JSON, is often relatively straightforward. For a lot of use cases, you won't need to know or understand a lot more than what I explain in this post. However, sometimes you need to dive deeper into Codable, and you end up writing custom encoding or decoding logic like I explain in this post.

In more advanced scenarios, you might need to have an extremely flexible approach to decoding data. For example, you might want to decode your data into an enum that has an associated value depending on one or more properties that exist in your JSON data.

I know that this post sounds like it's most likely covered by this Swift evolution proposal. The proposal covers a slightly different case than the case that I'd like to show you in this post.

So before I dive in, I want to show you the problem that SE-0295 solves before I move on to the main topic of this post.

Understanding the problem that SE-0295 solves

Without going into too much detail, the SE-0295 Swift Evolution proposal covers coverting data that has a key + value pair into an enum with an associated value by using the key from the data as the enum case, and the value for a given key as an associated value.

So for example (this example is copied from the linked proposal), it would allow you to decode the following JSON:

{
  "load": {
    "key": "MyKey"
  }
}

Into the following Decodable enum:

enum Command: Decodable {
  case load(key: String)
}

This is neat, but it doesn't quite cover every possible use case.

For example, imagine that you have a JSON response that looks a bit like this:

{
  "coupons": [
    {
      "type": "promo",
      "discount": 0.2,
      "code": "AEHD36"
    },
    {
      "type": "giftcard",
      "value": 12.0,
      "code": "GIFT_2816"
    }
  ]
}

Decoding this into a Coupon enum that has a promo and giftcard case, both with associated values for the other fields, is not possible with SE-0295. Instead. We can use a custom init(from:) and encode(to:) to add support for this.

Decoding a JSON object into an enum case with associated values

Given the JSON from the previous section, I would like to decode this into the following Decodable models:

struct Checkout: Decodable {
  let coupons: [Coupon]
}

enum Coupon: Decodable {
  case promo(discount: Float, code: String)
  case giftcard(value: Float, code: String)
}

When you write these models, you'll immediately see that Xcode complains. Coupon does not conform to Decodable because the compiler can't synthesize the init(from:) implementation for Coupon. Luckily, we can write this initializer ourselves:

enum Coupon: Decodable {
  case promo(discount: Float, code: String)
  case giftcard(value: Float, code: String)

  enum CodingKeys: String, CodingKey {
    case type, discount, code, value
  }

  enum CouponType: String, Decodable {
    case promo, giftcard
  }

  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    let type = try container.decode(CouponType.self, forKey: .type)
    let code = try container.decode(String.self, forKey: .code)

    switch type {
    case .promo:
      let discount = try container.decode(Float.self, forKey: .discount)
      self = .promo(discount: discount, code: code)
    case .giftcard:
      let value = try container.decode(Float.self, forKey: .value)
      self = .giftcard(value: value, code: code)
    }
  }
}

There's a lot to look at in this code snippet. If you haven't seen a custom implementation of init(from:) before, you might want to take a look at this post where I explain how you can write custom decoding and encoding logic for your Codable models.

The CodingKeys that I defined for this enum contains all of the keys that I might be interested in. These coding keys match up with the fields in my JSON, but they do not line up with my enum cases. That's because I'll use the value of the type in the data we're decoding to determine which enum case we're decoding. Since I'd like to have some type safety here, I decode the value of type into another enum. This enum only contains the cases that I consider valid and usually you'll find that they mirror your main enum's cases without their associated values.

In my init(from:) implementation you'll notice that I create a container using the CodingKeys. Next, I create two local variables that hold the type and code from the data that I'm decoding. Next, I use a switch to check what the value of type is. Based on the value of type I know which other value(s) to decode, and I know which enum case I should use for self.

This is pretty similar to how I added an other case to an enum in the post I linked to earlier.

This approach will work fine if our enum cases are simple. But what if we might have more complex objects that would total up to an unreasonable number of associated values?

In that case, you can use another Decodable object as each enum case's single associated value. Let's take a look at an updated version of the Coupon enum without the decoding logic:

enum Coupon: Decodable {
  case promo(PromoCode)
  case giftcard(Giftcard)

  enum CodingKeys: String, CodingKey {
    case type
  }
}

Both enum cases now have a single associated value that's represented by a struct. The CodingKeys object now has a single case; type. We'll write the two structs for the associated values first. After that, I'll show you the updated decoding logic for Coupon:

struct PromoCode: Decodable {
  let discount: Float
  let code: String
}

struct Giftcard: Decodable {
  let value: Float
  let code: String
}

These two structs shouldn't surprise you. They're just plain structs that match the JSON data that they're supposed to represent. The real magic is in the init(from:) for the Coupon:

// In Coupon
init(from decoder: Decoder) throws {
  let container = try decoder.container(keyedBy: CodingKeys.self)
  let type = try container.decode(CouponType.self, forKey: .type)

  let associatedContainer = try decoder.singleValueContainer()

  switch type {
  case .promo:
    let promo = try associatedContainer.decode(PromoCode.self)
    self = .promo(promo)
  case .giftcard:
    let card = try associatedContainer.decode(Giftcard.self)
    self = .giftcard(card)
  }
}

This init(from:) still extracts and switches on the type key from the data we're decoding. The real trick is in how I create instances of PromoCode and Giftcard using the single value container that I create right before the switch:

let promo = try associatedContainer.decode(PromoCode.self)
// and
let card = try associatedContainer.decode(Giftcard.self)

Instead of asking my container to decode an instance of PromoCode for a given key, I ask the decoder for a single value container. This is a new container based on all the data that we're decoding. I ask that container to decode a PromoCode. The reason I can't call container.decode on the original container to decode a PromoCode is that container.decode expects to decode an object for a key. This means that if you're thinking of it in terms of JSON, the data should look like this for container.decode to work:

{
  "type": "promo",
  "data": {
    "discount": 0.1,
    "code": "AJDK9"
  }
}

If we'd have data that looked like that, we would be able to decode PromoCode for a data key.

However, the data isn't nested; it's flat. So what we want is to decode two Swift objects for a single data object. To do that, you can pass create a second container that expects to extract a single value from the data. In this case, this means that we have one container based on our data to extract a type, and another container that's used to decode the full JSON object into the decodable object we're looking for. Pretty neat, right?

Once the associated value is decoded, I can assign the appropriate enum case and associated value to self, and the decoding is done. The error from earlier is unchanged in the updated code for Coupon.

With these shenanigans in place, we should also look at what the encoding logic for this enum with an associated object looks like.

Encoding an enum with an associated value into a single JSON object

Encoding the Coupon enum from the previous section unsurprisingly follows a similar pattern. We can first encode the type property, and then pass our encoder to the encode(to:) method on our associated value to encode the associated value. If you're following along, make sure you mark the Giftcard and PromoCode structs as Codable (or Encodable).

Let's look at the updated Coupon:

enum Coupon: Codable {
  case promo(PromoCode)
  case giftcard(Giftcard)

  enum CodingKeys: String, CodingKey {
    case type
  }

  init(from decoder: Decoder) throws {
    // existing implementation for init(from:)
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)

    switch self {
    case .promo(let promo):
      try container.encode("promo", forKey: .type)
      try promo.encode(to: encoder)
    case .giftcard(let card):
      try container.encode("giftcard", forKey: .type)
      try card.encode(to: encoder)
    }
  }
}

The encoding logic is hopefully pretty much what you expected. I check what self is, and based on that I encode a type. Next, I grab the associated value and call encode(to:) on the associated value with the Encoder that our instance of Coupon received. The end result is that both the type and the encoded associated value both exist on the same encoded object.

To check whether the encoding logic works as expected, you can take the JSON string from the beginning of this post, convert it to data, decode it, and then encode it again:

let decoder = JSONDecoder()
let checkout = try! decoder.decode(Checkout.self, from: Data(jsonString.utf8))

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(checkout)
print(String(data: data, encoding: .utf8)!)

The structure of the printed JSON will mirror that of the JSON you saw at the start of this post.

Good stuff!

In Summary

In this post, you saw how you can use some interesting techniques to decode a single JSON object (or any data object that can be decoded using Decodable) into multiple objects by passing around your Decoder to different objects. In this example, I used an enum with an associated value but I'm sure you can imagine that you could make this approach to decode into two or more structs if needed. I personally couldn't think of a good reason to do this so I decided to not cover it in this post. Just know that the approach would be identical to what you've seen in this post.

A huge thank you goes out to Josh Asbury for pointing out a couple of neat improvements to the init(from:) that I ended up using for the Coupon object.

If you want to learn more about interesting things that you can do with Codable, make sure to check out the codable category on this website.

Categories

Codable