Flattening a nested JSON response into a single struct with Codable
Published on: April 4, 2021Often, 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 and serves as basis for us to be building a flattening init(from:)
.
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 but it's very close to the kinds of JSON we sometimes have to work with in production. We can't change the backend in this case, 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. It's a good representation of the data for usage in an app but we can't go from our JSON to this struct directly without writing a custom init(from:)
that leverages multiple CodingKey
enums to map the source JSON to our struct.
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 if you've written a custom init(from:)
before.
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. You do, however have to define all intermediate structs which means that your gains are exclusively in the init(from:)
as shown in the example below:
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 flat struct into nested JSON data
Sometimes you'll need to be able to encode and decode your data in order to be able to fetch data from a server and then update it as needed. In these cases, you'll need to write some custom encoding logic to allow converting your flat struct back into the nested JSON data you started out with.
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.