The Decodable
protocol was introduced in Swift 4. Since then it has become the standard way for developers to decode JSON received from a remote server.
There are tons of tutorials out there that teach you how to utilize the Decodable
protocol to decode various types of JSON structure. However, all these tutorials do not cover one specific type of JSON structure — JSON with dynamic keys.
What do I mean by JSON with dynamic keys? Take a look at the following sample JSON that shows a list of students:
{
"S001": {
"firstName": "Tony",
"lastName": "Stark"
},
"S002": {
"firstName": "Peter",
"lastName": "Parker"
},
"S003": {
"firstName": "Bruce",
"lastName": "Wayne"
}
}
As you can see, the student ID is the key and the student information is the value.
As iOS developers, what we usually need is an array of students, so that the list of students can be easily displayed on a table view. Therefore, aside from decoding the JSON, we also need to flatten the result (make student ID part of the student object) and transform it into an array.
When facing such JSON structure, some developers might fall back to the old decoding approach by using the JSONSerialization
class and manually looping through and parsing each and every key-value pair.
However, I do not like the JSONSerialization
way because it is more error-prone. On top of that, we will lose all the benefits that come along with using Decodable
protocol.
In this article, I will walk you through the decoding approach that utilizes the Decodable
protocol. After that, I will make the decoding logic generic so that it can be reused by some other object type.
With all that being said, let’s get right into it.
Extracting the Values
As a recap, this is the JSON that we trying to decode:
{
"S001": {
"firstName": "Tony",
"lastName": "Stark"
},
"S002": {
"firstName": "Peter",
"lastName": "Parker"
},
"S003": {
"firstName": "Bruce",
"lastName": "Wayne"
}
}
For simplicity sake, let’s focus on decoding the firstName
and lastName
for now. We will get back to the student ID later.
First, let’s define a Student
struct that conforms to the Decodable
protocol.
struct Student: Decodable {
let firstName: String
let lastName: String
}
Next, we will need a Decodable
struct that contains a Student
array. We will use this struct to hold all the decoded Student
objects. Let’s call this struct DecodedArray
.
struct DecodedArray: Decodable {
var array: [Student]
}
In order to access the JSON’s dynamic keys, we must define a custom CodingKey
struct. This custom CodingKey
struct is needed when we want to create a decoding container from the JSONDecoder
.
struct DecodedArray: Decodable {
var array: [Student]
// Define DynamicCodingKeys type needed for creating
// decoding container from JSONDecoder
private struct DynamicCodingKeys: CodingKey {
// Use for string-keyed dictionary
var stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
// Use for integer-keyed dictionary
var intValue: Int?
init?(intValue: Int) {
// We are not using this, thus just return nil
return nil
}
}
}
Note that we are only interested in the string value initializer because our keys are of type string, therefore we can just return nil in the integer value initializer.
With all that in place, we can now start implementing the DecodedArray
initializer.
struct DecodedArray: Decodable {
var array: [Student]
// Define DynamicCodingKeys type needed for creating
// decoding container from JSONDecoder
private struct DynamicCodingKeys: CodingKey {
// Use for string-keyed dictionary
var stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
// Use for integer-keyed dictionary
var intValue: Int?
init?(intValue: Int) {
// We are not using this, thus just return nil
return nil
}
}
init(from decoder: Decoder) throws {
// 1
// Create a decoding container using DynamicCodingKeys
// The container will contain all the JSON first level key
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var tempArray = [Student]()
// 2
// Loop through each key (student ID) in container
for key in container.allKeys {
// Decode Student using key & keep decoded Student object in tempArray
let decodedObject = try container.decode(Student.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
tempArray.append(decodedObject)
}
// 3
// Finish decoding all Student objects. Thus assign tempArray to array.
array = tempArray
}
}
Let’s break down in detail what’s happening inside the initializer.
- Create a decoding container using the
DynamicCodingKeys
struct. This container will contain all the JSON’s first level dynamic keys. - Loop through each key to decode its respective
Student
object. - Store all the decoded
Student
objects into theStudent
array.
That’s it for extracting firstName
and lastName
. Let’s run all these in the Xcode playground to see them in action.
let jsonString = """
{
"S001": {
"firstName": "Tony",
"lastName": "Stark"
},
"S002": {
"firstName": "Peter",
"lastName": "Parker"
},
"S003": {
"firstName": "Bruce",
"lastName": "Wayne"
}
}
"""
let jsonData = Data(jsonString.utf8)
// Ask JSONDecoder to decode the JSON data as DecodedArray
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)
dump(decodedResult.array)
// Output:
//▿ 3 elements
//▿ __lldb_expr_21.Student
// - firstName: "Bruce"
// - lastName: "Wayne"
//▿ __lldb_expr_21.Student
// - firstName: "Peter"
// - lastName: "Parker"
//▿ __lldb_expr_21.Student
// - firstName: "Tony"
// - lastName: "Stark"
Here we convert the JSON string to data and ask the JSONDecoder
to decode the JSON data as DecodedArray
type. With that, we will be able to access all the decoded Student
objects via DecodedArray.array
.
Congratulations! You have successfully decoded all the Student
objects. However, there are still works to do. In the next section, we will look into adding the student ID into the Student
struct.
Extracting the Keys
With what we currently have, adding the student ID into the Student
struct is pretty straightforward. What we need to do is implement our own Student
initializer and manually decode lastName
, firstName
and studentId
.
Take a look at the following updated Student
struct:
struct Student: Decodable {
let firstName: String
let lastName: String
// 1
// Define student ID
let studentId: String
// 2
// Define coding key for decoding use
enum CodingKeys: CodingKey {
case firstName
case lastName
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// 3
// Decode firstName & lastName
firstName = try container.decode(String.self, forKey: CodingKeys.firstName)
lastName = try container.decode(String.self, forKey: CodingKeys.lastName)
// 4
// Extract studentId from coding path
studentId = container.codingPath.first!.stringValue
}
}
Let’s go through the changes we made on Student
struct one by one:
- Define
studentId
to hold the extracted key (student ID). - Define coding keys that are needed for manual decoding.
- Manually decode
firstName
andlastName
. - This is where the magic happens. The decoding container
codingPath
is an array ofCodingKey
that contains the path of coding keys taken to get to this point in decoding. For our case, it should contain the key we obtained fromDynamicCodingKeys
inDecodedArray
, which is the student ID.
Let’s run this again in Xcode playground to see the final result.
let jsonData = Data(jsonString.utf8)
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)
dump(decodedResult.array)
// Output:
//▿ 3 elements
//▿ __lldb_expr_37.Student
// - firstName: "Peter"
// - lastName: "Parker"
// - studentId: "S002"
//▿ __lldb_expr_37.Student
// - firstName: "Tony"
// - lastName: "Stark"
// - studentId: "S001"
//▿ __lldb_expr_37.Student
// - firstName: "Bruce"
// - lastName: "Wayne"
// - studentId: "S003"
With that, we have successfully decoded and flattened a JSON with dynamic keys using the Decodable
protocol. 🥳
In the next section, let’s go one step further by improving the DecodedArray
struct functionality and reusability.
Adding Custom Collection Support
If you take a closer look into the DecodedArray
struct, it is basically just a wrapper of the Student
array. This makes it the perfect candidate to transform into a custom collection.
By transforming into a custom collection, the DecodedArray
struct can take advantage of the array literal, as well as all the standard collection functionalities such as filtering and mapping.
First, let’s define a typealias
to represent the Student
array and update the other part of DecodedArray
accordingly. The typealias
is required when we conform the DecodedArray
to the Collection
protocol later.
Here’s the updated DecodedArray
where I have marked the changes made with ***
.
struct DecodedArray: Decodable {
// ***
// Define typealias required for Collection protocl conformance
typealias DecodedArrayType = [Student]
// ***
private var array: DecodedArrayType
// Define DynamicCodingKeys type needed for creating decoding container from JSONDecoder
private struct DynamicCodingKeys: CodingKey {
// Use for string-keyed dictionary
var stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
// Use for integer-keyed dictionary
var intValue: Int?
init?(intValue: Int) {
// We are not using this, thus just return nil
return nil
}
}
init(from decoder: Decoder) throws {
// Create decoding container using DynamicCodingKeys
// The container will contain all the JSON first level key
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
// ***
var tempArray = DecodedArrayType()
// Loop through each keys in container
for key in container.allKeys {
// Decode Student using key & keep decoded Student object in tempArray
let decodedObject = try container.decode(Student.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
tempArray.append(decodedObject)
}
// Finish decoding all Student objects. Thus assign tempArray to array.
array = tempArray
}
}
Next up, let’s extend the DecodedArray
and conform to the Collection
protocol.
extension DecodedArray: Collection {
// Required nested types, that tell Swift what our collection contains
typealias Index = DecodedArrayType.Index
typealias Element = DecodedArrayType.Element
// The upper and lower bounds of the collection, used in iterations
var startIndex: Index { return array.startIndex }
var endIndex: Index { return array.endIndex }
// Required subscript, based on a dictionary index
subscript(index: Index) -> Iterator.Element {
get { return array[index] }
}
// Method that returns the next index when iterating
func index(after i: Index) -> Index {
return array.index(after: i)
}
}
The details of conforming to the Collection
protocol are beyond the scope of this article. If you want to know more, I highly recommend this great article.
That’s about it, we have fully transformed the DecodedArray
struct into a custom collection.
Once again, let’s test out our changes in the Xcode playground. But this time with some cool functionalities that we gain from the Collection
protocol conformance — array literal, map
, and filter
.
let jsonData = Data(jsonString.utf8)
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)
// Array literal
dump(decodedResult[2])
//▿ __lldb_expr_5.Student
//- firstName: "Bruce"
//- lastName: "Wayne"
//- studentId: "S003"
// Map
dump(decodedResult.map({ $0.firstName }))
// Output:
//▿ 3 elements
//- "Tony"
//- "Peter"
//- "Bruce"
// Filter
dump(decodedResult.filter({ $0.studentId == "S002" }))
// Output:
//▿ __lldb_expr_1.Student
//- firstName: "Peter"
//- lastName: "Parker"
//- studentId: "S002"
Pretty cool isn’t it? With a little bit of extra effort, let’s make it even cooler by making the DecodedArray
generic so that we can reuse it on other object types.
Make It Generic, Increase Reusability
To make our DecodedArray
generic, we just need to add a generic parameter clause and replace all the Student
type with a placeholder type T
.
Once again, I have marked all the changes with ***
.
// ***
// Add generic parameter clause
struct DecodedArray<T: Decodable>: Decodable {
// ***
typealias DecodedArrayType = [T]
private var array: DecodedArrayType
// Define DynamicCodingKeys type needed for creating decoding container from JSONDecoder
private struct DynamicCodingKeys: CodingKey {
// Use for string-keyed dictionary
var stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
// Use for integer-keyed dictionary
var intValue: Int?
init?(intValue: Int) {
// We are not using this, thus just return nil
return nil
}
}
init(from decoder: Decoder) throws {
// Create decoding container using DynamicCodingKeys
// The container will contain all the JSON first level key
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var tempArray = DecodedArrayType()
// Loop through each keys in container
for key in container.allKeys {
// ***
// Decode T using key & keep decoded T object in tempArray
let decodedObject = try container.decode(T.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
tempArray.append(decodedObject)
}
// Finish decoding all T objects. Thus assign tempArray to array.
array = tempArray
}
}
With all this in place, we can now use it to decode any object types. To see that in action, let’s use our generic DecodedArray
to decode the following JSON.
{
"Vegetable": [
{ "name": "Carrots" },
{ "name": "Mushrooms" }
],
"Spice": [
{ "name": "Salt" },
{ "name": "Paper" },
{ "name": "Sugar" }
],
"Fruit": [
{ "name": "Apple" },
{ "name": "Orange" },
{ "name": "Banana" },
{ "name": "Papaya" }
]
}
The above JSON represents an array of Food
objects grouped by category. Thus, we must first define the Food
struct.
struct Food: Decodable {
let name: String
let category: String
enum CodingKeys: CodingKey {
case name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Decode name
name = try container.decode(String.self, forKey: CodingKeys.name)
// Extract category from coding path
category = container.codingPath.first!.stringValue
}
}
After defining the Food
struct, we are now ready to decode the given JSON using the generic DecodedArray
.
let jsonString = """
{
"Vegetable": [
{ "name": "Carrots" },
{ "name": "Mushrooms" }
],
"Spice": [
{ "name": "Salt" },
{ "name": "Paper" },
{ "name": "Sugar" }
],
"Fruit": [
{ "name": "Apple" },
{ "name": "Orange" },
{ "name": "Banana" },
{ "name": "Papaya" }
]
}
"""
let jsonData = Data(jsonString.utf8)
// Define DecodedArray type using the angle brackets (<>)
let decodedResult = try! JSONDecoder().decode(DecodedArray<[Food]>.self, from: jsonData)
// Perform flatmap on decodedResult to convert [[Food]] to [Food]
let allFood = decodedResult.flatMap{ $0 }
dump(allFood)
// Ouput:
//▿ 9 elements
//▿ __lldb_expr_11.Food
// - name: "Apple"
// - category: "Fruit"
//▿ __lldb_expr_11.Food
// - name: "Orange"
// - category: "Fruit"
//▿ __lldb_expr_11.Food
// - name: "Banana"
// - category: "Fruit"
//▿ __lldb_expr_11.Food
// - name: "Papaya"
// - category: "Fruit"
//▿ __lldb_expr_11.Food
// - name: "Salt"
// - category: "Spice"
//▿ __lldb_expr_11.Food
// - name: "Paper"
// - category: "Spice"
//▿ __lldb_expr_11.Food
// - name: "Sugar"
// - category: "Spice"
//▿ __lldb_expr_11.Food
// - name: "Carrots"
// - category: "Vegetable"
//▿ __lldb_expr_11.Food
// - name: "Mushrooms"
// - category: "Vegetable"
Do note that decodedResult
is an array of Food
arrays ([[Food]]
). Therefore, to get an array of Food
objects ([Food]
), we will apply flatmap
on decodedResult
to convert [[Food]]
to [Food]
.
Wrapping Up
This article only demonstrates decoding and flattening JSON with 2 layers, you can definitely apply the same concept on JSON with 3 or more layers. I’ll leave that as an exercise for you!
If you would like to try out the decoding approach on Xcode playground, here’s the full sample code.
What do you think about this decoding approach? Feel free to leave your comments or thoughts in the comment section below.
If you like this article, make sure to check out my other articles related to Swift.
You can also follow me on Twitter for more articles related to iOS development.
Thanks for reading. 🧑🏻💻
Further Readings
👋🏻 Hey!
While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.
- ✨ Bartender: Superpower your menu bar and take full control over your menu bar items.
- ✨ CleanShot X: The best screen capture app I’ve ever used.
- ✨ PixelSnap: Measure on-screen elements with ease and precision.
- ✨ iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.