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,
- 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,
- employeeName - employee_name
- employeeAge - employee_age
- 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: