Codable - Dealing with built-in and custom `keyDecodingStrategy`

Apple introduced a keyDecodingStrategy parameter as a part of Swift 4.1 release. Although it's been out for a while, it's a huge change. We have many instances where server and mobile apps differ the way in which they represent incoming data. In most cases apps rely on camelCase notations while server sends data in snake_case representation.

Today I am going to write about how we will be able to utilize keyDecodingStrategy property associated with Codable object to be able to decode (Or encode) any payload we want.

keyDecodingStrategy is an enum of type KeyDecodingStrategy which has following structure with 3 cases,

public enum KeyDecodingStrategy {

    /// Use the keys specified by each type. This is the default strategy.
    case useDefaultKeys

    /// Convert from "snake_case_keys" to "camelCaseKeys" before attempting to match a key with the one specified by each type.        
    case convertFromSnakeCase

    /// Provide a custom conversion from the key in the encoded JSON to the keys specified by the decoded types.
    /// The full path to the current decoding position is provided for context (in case you need to locate this key within the payload). The 
    /// returned key is used in place of the last component in the coding path before decoding.
    case custom(([CodingKey]) -> CodingKey)
}

Now we will take at each case one by one,

  1. useDefaultKeys

This is the simplest type. It says whichever keys present in the incoming payload, map them exactly as it is on the exact keys represented by the model object conforming to Decodable protocol.

This also represents the low overhead since OS doesn't have to do any heavy lifting converting keys from JSON representation to the one on object models. Taken from Apple documentation,

Key decoding strategies other than JSONDecoder.KeyDecodingStrategy.useDefaultKeys may have a noticeable performance cost because those strategies may inspect and transform each key.

If keys do not match, then decoder.decode(Type.self, from: data) will throw an exception with appropriate error object. Let's take a look at the example,

Suppose our object model conforming to Decodable looks like this,

struct Employee: Decodable {

    let employeeName: String
    let employeeAge: Int
    let employeeAddress: String
}

And this is the incoming JSON we are looking to decode,

let employeeJSON = """
                {"employeeName": "abc", "employeeAge": 40, "employeeAddress": "USA"}
            """

As you can see the keys in JSON and object model match exactly. So using useDefaultKeys strategy makes sense. Please note that this is a default strategy and we do not need to explicitly set it so.

Here is how decoding goes,

do {
    let jsonData = employeeJSON.data(using: .utf8)!
    let decoder = JSONDecoder()    
    let decodedData = try decoder.decode(Employee.self, from: jsonData)

    // Prints
    // Employee(employeeName: "abc", employeeAge: 40, employeeAddress: "USA")

    print(decodedData)
} catch {
    print(error.localizedDescription)
}

2. convertFromSnakeCase

This is the next little advanced case of how we want to decode incoming JSON. We use this strategy when JSON keys do not quite match to object model, but they are snake case representations of original camel case keys defined on model.

For example, if they key in your incoming response looks like this, my_awesome_key and the corresponding key on model is myAwesomeKey this decoding strategy allows us to do direct mapping without having to rely on manual manipulation.

As before, we are going to keep our Employee model unchanged

struct Employee: Decodable {

    let employeeName: String
    let employeeAge: Int
    let employeeAddress: String
}

However, unlike previous case the server sends down the JSON payload with snake_case keys instead,

let employeeJSON = """
            {"employee_name": "abc", "employee_age": 40, "employee_address": "USA"}
        """

If we make a manual list of all such mappings, we get following output,

  1. employeeName - employee_name
  2. employeeAge - employee_age
  3. employeeAddress - employee_address

This is where the keyDecodingStrategy of convertFromSnakeCase comes into a picture.

do {
    let jsonData = employeeJSON.data(using: .utf8)!
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    let decodedData = try decoder.decode(Employee.self, from: jsonData)
    
    // Prints
    // Employee(employeeName: "abc", employeeAge: 40, employeeAddress: "USA")
    
    print(decodedData)
} catch {
    print(error.localizedDescription)
}

And voilà! We've successfully converted snake_case keys from incoming JSON into corresponding camelCase on object models, and that too without any custom code.

3. Using Custom key decoding strategy

This is slightly more complicated case compared to other decoding strategies, and it is represented by case custom(([CodingKey]) -> CodingKey).

Earlier decoding strategies we saw were simple that we didn't need conversion at all or conversion happened with known rules. (Converting snake case to camel case and vice versa is a known technique).

But what happens when you want to convert JSON keys into model object properties using custom rule? This is where custom decoding strategy comes into picture.

Let's start with the weird JSON response.

let weirdJSON = """
    {"employee_name_1101": "abc", "employee_age_2102": 40, "employee_address_9103": "USA"}
"""

Suppose our backend have a dark sense of humor. So instead of sending regular response with predefined keys, they want to make our life harder by suffixing each key with random 4 digit number. Now, if these numbers would've been predefined, we didn't even need to go with custom decoding strategy. Last 4 digits could be anything and we will probably never know what they're going to be in advance.

As usual, we are going to keep our Employee model unchanged,

struct Employee: Decodable {

    let employeeName: String
    let employeeAge: Int
    let employeeAddress: String
}

Since model object properties are using camelCase standard, we will first apply the snake_case to camelCase conversion before proceeding. This the utility in the String extension.


// Ref: https://gist.github.com/bhind/c96ee94b5f6ac2b870f4488619786141
extension String {

    static private let SNAKECASE_PATTERN:String = "(\\w{0,1})_"

    func snake_caseToCamelCase() -> String {
        let buf:NSString = self.capitalized.replacingOccurrences( of: String.SNAKECASE_PATTERN,
                                                      with: "$1",
                                                      options: .regularExpression,
                                                      range: nil) as NSString
        return buf.replacingCharacters(in: NSMakeRange(0,1), with: buf.substring(to: 1).lowercased()) as String
    }
}

Since we want to rid of last 4 digits, here's another String utility to remove last n characters from input string


extension String {

    func remove(last n: Int) -> String {
        if self.count <= n {
            return ""
        }
        return String(self.dropLast(n))
    }
}

One last step, as referenced here, the custom decoding block returns an object conforming to CodingKey. So we will implement this protocol with concrete type AnyKey and transform the converted string into object that conforms to CodingKey

// Ref: https://developer.apple.com/documentation/foundation/jsondecoder/keydecodingstrategy/custom
struct AnyKey: CodingKey {
    var stringValue: String
    var intValue: Int?

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

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

And now with utilities at hand we will move to the decoding operation,

do {
    let jsonData = weirdJSON.data(using: .utf8)!
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .custom({ key -> CodingKey in
        
        // Transforms _JSONKey(stringValue: "employee_address_9103", intValue: nil) to employee_address_9103        
        let rawKey = key.last!.stringValue
        
        // Transforms employee_address_9103 to employeeAddress9103
        let camelCaseValue = rawKey.snake_caseToCamelCase()
        
        // Transforms employeeAddress9103 to employeeAddress after dropping last 4 digits
        let valueAfterDroppingEndCharacters = camelCaseValue.remove(last: 4)
        
        // Conversion of raw string employeeAddress into an object conforming to CodingKey
        return AnyKey(stringValue: valueAfterDroppingEndCharacters)!
    })
    let decodedData = try decoder.decode(Employee.self, from: jsonData)
    
    // Prints
    // Employee(employeeName: "abc", employeeAge: 40, employeeAddress: "USA")
    
    print(decodedData)
} catch {
    print(error.localizedDescription)
}

References:

  1. Swift implementation to convert the snake case string into camel case
  2. Apple - Using Custom decoding strategy