Learn how to use dynamic coding keys in your app

Introduction

In mobile development, one common task is fetching data from a server and displaying it in your app. In iOS development using Swift, this often involves decoding JSON data and presenting it to users. While it's relatively straightforward to decode JSON and display the data, there are scenarios where the code can become less scalable and more difficult to maintain. This tutorial will address one such scenario by introducing dynamic coding keys, which provide a flexible approach to handling JSON data with varying keys. By leveraging dynamic coding keys, you can make your code more readable and adaptable to changes in the backend data.

Getting Started

To follow along with this tutorial, you can download the starter project here.

Problem

The starter project is a simple app that decodes a list of homes for sale and displays some data about the homes to the user. Let's take a look at an example home from the JSON data:

{
    "id": 1,
    "address": "123 Main St",
    "city": "Exampleville",
    "state": "Exampshire",
    "zip": "12345",
    "price": 250000,
    "bedrooms": 3,
    "bathrooms": 2,
    "description": "This beautiful home features a spacious living room, modern kitchen, and a backyard garden.",
    "amenity1": "Swimming Pool",
    "amenity2": "Fireplace",
    "amenity3": "Garage",
    "amenity4": "Garden",
    "amenity5": "",
    "amenity6": "",
    "amenity7": "",
    "amenity8": "",
    "amenity9": "",
    "amenity10": "",
    "photos": [
        "https://example.com/photos/1.jpg",
        "https://example.com/photos/2.jpg",
        "https://example.com/photos/3.jpg"
    ]
}

At first glance, this JSON seems straightforward. You might be tempted to write code to fetch and decode this data, and display it to the user. However, there is a problem with the way this JSON is formatted. Notice the amenities section with keys like "amenity1", "amenity2", and so on. This solution requires hard-coding each amenity as a separate property in the Home struct. But what happens when new amenities are added? We would need to update our code and release a new version of the app. This is not ideal, as it introduces more room for errors and makes our code less adaptable to changes in the backend data. A naive solution may look something like this:

struct Home: Codable {
    let id: Int
    let address: String
    let city: String
    let state: String
    let zip: String
    let price: Int
    let bedrooms: Int
    let bathrooms: Int
    let description: String
    
    private let amenity1: String
    private let amenity2: String
    private let amenity3: String
    private let amenity4: String
    private let amenity5: String
    private let amenity6: String
    private let amenity7: String
    private let amenity8: String
    private let amenity9: String
    private let amenity10: String
    
    init(id: Int, address: String, city: String, state: String, zip: String, price: Int, bedrooms: Int, bathrooms: Int, description: String, photos: [String], amenity1: String, amenity2: String, amenity3: String, amenity4: String, amenity5: String, amenity6: String, amenity7: String, amenity8: String, amenity9: String, amenity10: String) {
        self.id = id
        self.address = address
        self.city = city
        self.state = state
        self.zip = zip
        self.price = price
        self.bedrooms = bedrooms
        self.bathrooms = bathrooms
        self.description = description
        self.amenity1 = amenity1
        self.amenity2 = amenity2
        self.amenity3 = amenity3
        self.amenity4 = amenity4
        self.amenity5 = amenity5
        self.amenity6 = amenity6
        self.amenity7 = amenity7
        self.amenity8 = amenity8
        self.amenity9 = amenity9
        self.amenity10 = amenity10
    }
    
    func getAmenities() -> [String] {
        return [amenity1, amenity2, amenity3, amenity4, amenity5, amenity6, amenity7, amenity8, amenity9, amenity10].filter { !$0.isEmpty }
    }
}

Solution Approach

To address the limitations of the current solution, we will leverage dynamic coding keys. Dynamic coding keys allow us to handle varying keys in the JSON data without the need for hard-coding each property. With this approach, our code can gracefully handle new amenities being added without requiring manual updates and releases.

Step 1: Refining the Data Model

First, we will enhance the data model to effectively handle the dynamic nature of amenities. We'll introduce a new property, amenities, as a list to store all the amenities associated with a home. This modification allows for seamless expansion of amenity options without the need to modify the codebase. Here's an updated version of the Home struct:

struct Home: Codable {
    let id: Int
    let address: String
    let city: String
    let state: String
    let zip: String
    let price: Int
    let bedrooms: Int
    let bathrooms: Int
    let description: String

    private var amenities: [String] = []

    func getAmenities() -> [String] {
        return amenities
    }
}

Step 2: Implementing Dynamic Coding Keys

To support dynamic decoding, we will introduce a new DynamicKey struct that conforms to the CodingKey protocol. This struct enables us to handle the variable nature of the keys in the JSON data. Here's an example implementation:

struct DynamicKey: CodingKey {
    var stringValue: String
    var intValue: Int?

    init?(stringValue: String) {
        self.stringValue = stringValue
    }

    init?(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }
}

Step 3: Updating the Decoding Process

Next, we will update the decoding process to utilize dynamic coding keys instead of the default coding keys. This change ensures that our code can adapt to varying key names in the JSON data. Here's the updated decoding logic:

struct Home: Codable {
    // ...

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DynamicKey.self)

        // Handle decoding for each property
        if let idKey = DynamicKey(stringValue: "id"), let id = try container.decodeIfPresent(Int.self, forKey: idKey) {
            self.id = id
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "id")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'id' is missing"))
        }

        // Repeat the above decoding process for other properties

        // Handling amenities decoding dynamically
        var amenitiesArr: [String] = []
        for key in container.allKeys {
            if key.stringValue.hasPrefix("amenity"), let amenity = try container.decodeIfPresent(String.self, forKey: key) {
                if amenity.isEmpty { continue }
                amenitiesArr.append(amenity)
            }
        }
        self.amenities = amenitiesArr
    }

    // ...
}

That means, after updating everything, you should see something very similar to this.

struct DynamicKey: CodingKey {
    var stringValue: String
    var intValue: Int?

    init?(stringValue: String) {
        self.stringValue = stringValue
    }

    init?(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }
}

struct Home: Codable {
    let id: Int
    let address: String
    let city: String
    let state: String
    let zip: String
    let price: Int
    let bedrooms: Int
    let bathrooms: Int
    let description: String

    private var amenities: [String] = []

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DynamicKey.self)

        if let idKey = DynamicKey(stringValue: "id"), let id = try container.decodeIfPresent(Int.self, forKey: idKey) {
            self.id = id
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "id")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'id' is missing"))
        }

        if let addressKey = DynamicKey(stringValue: "address"), let address = try container.decodeIfPresent(String.self, forKey: addressKey) {
            self.address = address
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "address")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'address' is missing"))
        }

        if let cityKey = DynamicKey(stringValue: "city"), let city = try container.decodeIfPresent(String.self, forKey: cityKey) {
            self.city = city
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "city")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'city' is missing"))
        }

        if let stateKey = DynamicKey(stringValue: "state"), let state = try container.decodeIfPresent(String.self, forKey: stateKey) {
            self.state = state
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "state")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'state' is missing"))
        }

        if let zipKey = DynamicKey(stringValue: "zip"), let zip = try container.decodeIfPresent(String.self, forKey: zipKey) {
            self.zip = zip
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "zip")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'zip' is missing"))
        }

        if let priceKey = DynamicKey(stringValue: "price"), let price = try container.decodeIfPresent(Int.self, forKey: priceKey) {
            self.price = price
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "price")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'price' is missing"))
        }

        if let bedroomsKey = DynamicKey(stringValue: "bedrooms"), let bedrooms = try container.decodeIfPresent(Int.self, forKey: bedroomsKey) {
            self.bedrooms = bedrooms
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "bedrooms")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'bedrooms' is missing"))
        }

        if let bathroomsKey = DynamicKey(stringValue: "bathrooms"), let bathrooms = try container.decodeIfPresent(Int.self, forKey: bathroomsKey) {
            self.bathrooms = bathrooms
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "bathrooms")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'bathrooms' is missing"))
        }

        if let descriptionKey = DynamicKey(stringValue: "description"), let description = try container.decodeIfPresent(String.self, forKey: descriptionKey) {
            self.description = description
        } else {
            throw DecodingError.keyNotFound(DynamicKey(stringValue: "bathrooms")!, DecodingError.Context(codingPath: [], debugDescription: "Required property 'bathrooms' is missing"))
        }

        var amenitiesArr: [String] = []
        for key in container.allKeys {
            if key.stringValue.hasPrefix("amenity"), let amenity = try container.decodeIfPresent(String.self, forKey: key) {
                if amenity.isEmpty { continue }
                amenitiesArr.append(amenity)
            }
        }
        self.amenities = amenitiesArr
    }

    func getAmenities() -> [String] {
        return amenities
    }
}

With this updated decoding logic, we can successfully decode all the properties, including the dynamically named amenities.

By incorporating dynamic coding keys, we have improved the flexibility and adaptability of our codebase. The updated solution gracefully handles varying keys in the JSON data, allowing for the addition of new amenities without manual code modifications. This approach ensures a robust and future-proof implementation, enabling smooth integration with the evolving data structure.

One more thing to note here. You may be thinking that the JSON is formatted poorly and it should be updated to just have a list of amenities instead of listing them one-by-one. Honestly, you're right. However, you may not have the power to change this depending on where you work. So it's important that you understand how to handle whatever is thrown at you, just in case your recommendations go unnoticed.

Final Solution

This is already a great solution, but let's make it even better by improving the code readability and reducing redundancy.

Currently, each required property is individually decoded and checked for presence. We can simplify this process by creating a helper function that handles the decoding and error throwing for us.

We'll add a private function called decodeRequiredWithKey that takes the type of the property, the key name, and the decoding container. This function will attempt to decode the value for the given key and throw an error if the value is missing.

private func decodeRequiredWithKey<T: Decodable>(_ type: T.Type, key: String, container: KeyedDecodingContainer<DynamicKey>) throws -> T {
    guard let dynamicKey = DynamicKey(stringValue: key) else {
        throw DynamicKeyError.dynamicKeyNotFound
    }
    
    do {
        return try container.decode(type, forKey: dynamicKey)
    } catch DecodingError.keyNotFound {
        throw DecodingError.keyNotFound(dynamicKey, DecodingError.Context(codingPath: [], debugDescription: "Required property '\(key)' is missing"))
    } catch {
        throw DynamicKeyError.valueNotFound
    }
}

Now, instead of individually decoding and checking each property, we can use the decodeRequiredWithKey function for each required property. This makes the code more concise and improves maintainability.

self.id = try decodeRequiredWithKey(Int.self, key: "id", container: container)
self.address = try decodeRequiredWithKey(String.self, key: "address", container: container)
self.city = try decodeRequiredWithKey(String.self, key: "city", container: container)
self.state = try decodeRequiredWithKey(String.self, key: "state", container: container)
self.zip = try decodeRequiredWithKey(String.self, key: "zip", container: container)
self.price = try decodeRequiredWithKey(Int.self, key: "price", container: container)
self.bedrooms = try decodeRequiredWithKey(Int.self, key: "bedrooms", container: container)
self.bathrooms = try decodeRequiredWithKey(Int.self, key: "bathrooms", container: container)
self.description = try decodeRequiredWithKey(String.self, key: "description", container: container)

This means you'll neeed to set some default values for all properites of your code, but I still think it makes it cleaner. The final solution is:

struct DynamicKey: CodingKey {
    var stringValue: String
    var intValue: Int?

    init?(stringValue: String) {
        self.stringValue = stringValue
    }

    init?(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }
}

enum DynamicKeyError: Error {
    case dynamicKeyNotFound
    case valueNotFound
}

struct Home: Codable {
    var id: Int = 0
    var address: String = ""
    var city: String = ""
    var state: String = ""
    var zip: String = ""
    var price: Int = 0
    var bedrooms: Int = 0
    var bathrooms: Int = 0
    var description: String = ""

    private var amenities: [String] = []
    
    private func decodeRequiredWithKey<T: Decodable>(_ type: T.Type, key: String, container: KeyedDecodingContainer<DynamicKey>) throws -> T {
        guard let dynamicKey = DynamicKey(stringValue: key) else {
            throw DynamicKeyError.dynamicKeyNotFound
        }
        
        do {
            return try container.decode(type, forKey: dynamicKey)
        } catch DecodingError.keyNotFound {
            throw DecodingError.keyNotFound(dynamicKey, DecodingError.Context(codingPath: [], debugDescription: "Required property '\(key)' is missing"))
        } catch {
            throw DynamicKeyError.valueNotFound
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DynamicKey.self)
        
        self.id = try decodeRequiredWithKey(Int.self, key: "id", container: container)
        self.address = try decodeRequiredWithKey(String.self, key: "address", container: container)
        self.city = try decodeRequiredWithKey(String.self, key: "city", container: container)
        self.state = try decodeRequiredWithKey(String.self, key: "state", container: container)
        self.zip = try decodeRequiredWithKey(String.self, key: "zip", container: container)
        self.price = try decodeRequiredWithKey(Int.self, key: "price", container: container)
        self.bedrooms = try decodeRequiredWithKey(Int.self, key: "bedrooms", container: container)
        self.bathrooms = try decodeRequiredWithKey(Int.self, key: "bathrooms", container: container)
        self.description = try decodeRequiredWithKey(String.self, key: "description", container: container)
        
        var amenitiesArr: [String] = []
        for key in container.allKeys {
            if key.stringValue.hasPrefix("amenity"), let amenity = try container.decodeIfPresent(String.self, forKey: key) {
                if amenity.isEmpty { continue }
                amenitiesArr.append(amenity)
            }
        }
        self.amenities = amenitiesArr
    }

    func getAmenities() -> [String] {
        return amenities
    }
}

Conclusion

I hope all your decoding is clean and easy, but if not, I hope you're a bit more prepard on how to tackle whatever is thrown your way. If you'd like to find the source code for this project you can do so here.