Flattening a nested JSON response into a single struct with Codable

Published on: April 4, 2021

Often, you'll want you Swift models to resemble JSON that's produced by an external source, like a server, as closely as possible. However, there are times when the JSON you receive is nested several levels deep and you might not consider this appropriate or needed for your application. Or maybe you're only interested in a couple of fields from the JSON response and these fields are hidden several levels deep in the JSON that's returned by a server.

In this post I'll show you how you can use nested containers to decode nested JSON data into a flat struct with a custom init(from:) implementation.

If you're not familiar with implementing a custom init(from:) method, take a look at this post. It describes custom encoding and decoding logic in detail.

Decoding nested JSON data into a single struct

Consider the follow JSON data:

{
  "id": 10,
  "contact_info": {
    "email": "[email protected]"
  },
  "preferences": {
    "contact": {
      "newsletter": true
    }
  }
}

There's a lot of nesting here, and in this case all of this nesting is kind of noisy. We can't change the backend, so let's see how this JSON can be decoded into the following struct:

struct User: Decodable {
  let id: Int
  let email: String
  let isSubscribedToNewsletter: Bool
}

This struct does not represent our JSON at all, but a custom init(from:) can work wonders:

struct User: Decodable {
  let id: Int
  let email: String
  let isSubscribedToNewsletter: Bool

  enum OuterKeys: String, CodingKey {
    case id, preferences
    case contactInfo = "contact_info"
  }

  enum ContactKeys: String, CodingKey {
    case email
  }

  enum PreferencesKeys: String, CodingKey {
    case contact
  }

  enum ContactPreferencesKeys: String, CodingKey {
    case newsletter
  }

  init(from decoder: Decoder) throws {
    let outerContainer = try decoder.container(keyedBy: OuterKeys.self)
    let contactContainer = try outerContainer.nestedContainer(keyedBy: ContactKeys.self,
                                                              forKey: .contactInfo)
    let preferencesContainer = try outerContainer.nestedContainer(keyedBy: PreferencesKeys.self,
                                                                  forKey: .preferences)
    let contactPreferencesContainer = try preferencesContainer.nestedContainer(keyedBy: ContactPreferencesKeys.self,
                                                                               forKey: .contact)

    self.id = try outerContainer.decode(Int.self, forKey: .id)
    self.email = try contactContainer.decode(String.self, forKey: .email)
    self.isSubscribedToNewsletter = try contactPreferencesContainer.decode(Bool.self, forKey: .newsletter)
  }
}

In this example I've defined several coding key enums. Each enum represents one of the JSON objects that I want to flatten into the User struct.

In the init(from:) method, the first like should look familiar to you:

let outerContainer = try decoder.container(keyedBy: OuterKeys.self)

This line extracts a container that uses the keys in my OuterKeys enum. The lines after this line are probably new to you:

let contactContainer = try outerContainer.nestedContainer(keyedBy: ContactKeys.self,
                                                          forKey: .contactInfo)
let preferencesContainer = try outerContainer.nestedContainer(keyedBy: PreferencesKeys.self,
                                                              forKey: .preferences)
let contactPreferencesContainer = try preferencesContainer.nestedContainer(keyedBy: ContactPreferencesKeys.self,
                                                                           forKey: .contact)

Instead of extracting a container from the decoder instance, I extract containers from other containers. These containers are keyed by their respective enums and they allow me to dig into the JSON data to get to the data I'm interested in.

In this case, that means that I can extract the id from the outerContainer, the email from the contactContainer and lastly, I can extract the value for isSubscribedToNewsletter from the contactPreferencesContainer.

Using nested container can be a super powerful approach to flattening your JSON data but maybe you're just looking for a way to provide a flattened struct and you don't mind defining the Decodable structs that mirror your JSON data. If that's the case, you can simplify your init(from:) quite a bit, and you don't need to write custom coding keys for every intermediate object in your JSON:

struct User: Decodable {
  let id: Int
  let email: String
  let isSubscribedToNewsletter: Bool

  enum CodingKeys: String, CodingKey {
    case id, preferences
    case contactInfo = "contact_info"
  }

  struct ContactInfo: Decodable {
    let email: String
  }

  struct Preferences: Decodable {
    let contact: ContactPreferences

    struct ContactPreferences: Decodable {
      let newsletter: Bool
    }
  }

  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    let contactInfo = try container.decode(ContactInfo.self, forKey: .contactInfo)
    let preferences = try container.decode(Preferences.self, forKey: .preferences)

    self.id = try container.decode(Int.self, forKey: .id)
    self.email = contactInfo.email
    self.isSubscribedToNewsletter = preferences.contact.newsletter
  }
}

This approach for decoding the data was pointed out to me by Filip Němeček as an alternative that's easier to understand. I definitely agree that not needing the intermediate containers can be a fantastic bonus. I'll leave it up to you to decide which solution you like better; they each have their own merit in my opinion.

Each of these two approaches take a little bit of extra work compared to having a model that mirror your JSON data but the result of this flattening is quite nice and it doesn't make using your JSONDecoder any more complex:

let decoder = JSONDecoder()
let user = try! decoder.decode(User.self, from: jsonData)

While it's nice that we can flatten this data, let's see how we can write a custom encode(to:) implementation that would allow us to encode and send this User object back to a server in its original shape.

Encoding a struct into nested JSON data

As usual, the encoding part of this example is very simliar to the decoding part. Let's look at the encoding counterpart for the first flattening approach:

struct User: Codable {
  let id: Int
  let email: String
  let isSubscribedToNewsletter: Bool

  // coding keys

  init(from decoder: Decoder) throws {
    // unchanged
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: OuterKeys.self)
    var contactContainer = container.nestedContainer(keyedBy: ContactKeys.self,
                                                     forKey: .contactInfo)
    var preferencesContainer = container.nestedContainer(keyedBy: PreferencesKeys.self,
                                                         forKey: .preferences)
    var contactPreferencesContainer = preferencesContainer.nestedContainer(keyedBy: ContactPreferencesKeys.self,
                                                                           forKey: .contact)

    try container.encode(id, forKey: .id)
    try contactContainer.encode(email, forKey: .email)
    try contactPreferencesContainer.encode(isSubscribedToNewsletter, forKey: .newsletter)
  }
}

Note that I've omitted the implementation for init(from:) and the coding key enums. They are unchanged from the previous section.

The implementation for encode(to) follows the exact same pattern as init(from:). I create all the containers using their respective coding keys, and then I encode the properties of User into the appropriate containers.

Let's take a look at the alternative approach that uses intermediate structs instead of coding keys next:

struct User: Codable {
  let id: Int
  let email: String
  let isSubscribedToNewsletter: Bool

  // coding keys and structs

  init(from decoder: Decoder) throws {
    // unchanged
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    let contactPreferences = Preferences.ContactPreferences(newsletter: isSubscribedToNewsletter)
    let preferences = Preferences(contact: contactPreferences)
    let contactInfo = ContactInfo(email: email)

    try container.encode(id, forKey: .id)
    try container.encode(preferences, forKey: .preferences)
    try container.encode(contactInfo, forKey: .contactInfo)
  }
}

In order to encode the original structs into my encoder, I need to create instances of these structs by hand. In this case, that's not a big deal; my structs are very small so this only takes a couple of lines of code.

After initializing my structs, I encode them into my container using the coding keys that I originally used to extract the same structs in my init(from:).

If you would decode the data from the beginning of this post into a User and then back into Data, you'll see that the JSON structure is identical with this approach. Nice!

In Summary

In this post I showed you how you can use a custom init(from:) to flatten nested JSON data into a single struct by writing your own init(from:) that created several keyed containers based on the different nested objects in the JSON data we're decoding. I also showed you an alternative approach that uses intermediate structs to decode the data and eventually assigned values from the decoded objects to my flattened struct. As I said in the section, I'll leave it up to you to decide which approach you prefer; I like them both. After showing you how to decode nested data, you saw how you can encode a flat struct into nested JSON data.

Writing your own encoding and decoding logic to perform radical transformations like this is something you'll rarely do. It's often more work than it's worth, and it's generally good to have your models mirror the data that you fetch from a remote source. Whether flattening JSON data into a single struct is a good idea will always depend on your reasons and use case. This post is not intended to be advice; it's intended to show you one of the many interesting things that can be done with Swift's encoding and decoding tools.

Categories

Codable