Right, that would be an even better place to handle inclusion or exclusion from having the keys decoded.
I have been experiencing some other issues with keyDecodingStrategies - issues that I think could easily lead to bugs.
These issues have made me think of an entirely different way of performing key mappings.
I think this could perhaps be made into a pitch - and without having actually tried out the implementation yet (it requires features of Swift 4.2), I think that it is doable.
Ok, so here goes my pitch for a pitch for a proposal:
More robust key coding strategies for Encoders
and Decoders
.
In Swift 4.1, JSONDecoder
and JSONEncoder
added the options of performing a mapping of keys through the keyDecodingStrategy
and keyEncodingStrategy
properties.
I see two issues with the current solution.
The first issue is that I found it unclear which key
is referred to in the keyEncodingStrategy
and keyDecodingStrategy
respectively.
One refers to a CodingKey
(the keyEncodingStrategy
maps from a CodingKey
value to a String
key) and the other refers to a key in the JSON structure (as the keyDecodingStrategy
maps from a String
key to a CodingKey
value).
This brings us to the second issue. Namely that the above means that we have two mapping functions:
(CodingKey) -> String
and
(String) -> CodingKey
In practise this can lead to unexpected mappings.
Consider the following type:
struct MyType: Codable {
let imageURL: URL
}
Using a keyEncodingStrategy
of .convertToSnakeCase
, this will lead to the key:
image_url
.
All fine and dandy.
But applying the keyDecodingStrategy
of .convertFromSnakeCase
will make the conversion fail, since the generated CodingKeys
enum will be initialized with the converted string key: "imageUrl"
, which of course does not exist.
This can of course be remedied by adding a CodingKeys
enum as follows:
struct MyType: Codable {
let imageURL: URL
enum CodingKeys: String, CodingKey {
case imageURL = "imageUrl"
}
}
Now the key decoding matches again, but there is a serious cognitive overhead required here.
Basically the thought process in creating the CodingKey case requires one to perform the conversion to snake case and then convert that back to camel case.
It is not easy.
Further similar examples that I think are harder to grasp than they should be:
Actually trying to map a property name to a different key name:
I want to map the property: internalName to the key: "my_more_elaborate_external_name"
This has to be done like so:
struct MyType: Codable {
let internalName: String
enum CodingKeys: String, CodingKey {
case internalName = "myMoreElaborateExternalName"
}
}
A final example deals with the situation where you have already manually mapped to snake case. Since the key that will be passed to the CodingKeys initializer when decoding is already converted to camelCase, this will naturally fail.
struct MyType: Codable {
let alreadyMapped: String
enum CodingKeys: String, CodingKey {
case alreadyMapped = "already_mapped"
}
}
Finally there is the case of model values of type Dictionary
. Since a keyDecodingStrategy
of .convertFromSnakeCase
converts all JSON keys, it currently also converts keys of the Dictionary
to camel case. But only if the JSON keys contain underscores. This can cause confusion (bugs) since the data being parsed may not always contain underscores, so you may only experience this a long while after putting code into production.
Consider:
struct Profile: Codable {
let name: String
let identifier: String
let accounts: [String: Bool]
}
Let's imagine that accounts
is intended to hold a map from an auto generated account identifier to a Bool.
Parsing the following JSON structure (based on a true story)
{
"name": "Morten",
"identifier": "63bci9n2",
"accounts": {
"-L3SATbsBxyXgju9Pqem": true
"-L3S5hBZ7elsJ1yX6mQg": true
"-Kr0epMgHcLs9_koxE1Q": true
}
}
At the time of actually using an account identifiers, the two first are decoded without any conversion, but the last identifier is decoded to "-kr0epmghcls9Koxe1Q"
which may have caused a bit of confusion for the user.
Proposed solution
I propose that there should only be one key mapping function. Namely the one from
(CodingKey) -> String
.
When decoding, one should not perform an inverse conversion (which is not always possible, as can be seen in the examples where the source is "imageURL"
or "already_snake_cased"
).
Rather, when the CodingKey
-conforming type is iterable, all keys should be converted, and the key used for decoding should be looked up in a map from the converted values to the original.
This has a few implications:
- Only types where the keys can be enumerated can have key coding strategies applied. (This will basically fix my issue with
Dictionary
keys being converted).
- The
CodingKey
can be written as you like. "image_url"
, "imageURL"
and "imageUrl"
all map to "image_url"
using 'to snake case' conversion.
This strategy will solve all the issues described above issues.
(Somewhat) detailed design:
Key encoding and decoding strategies should only be applied to CodingKey
-conforming types that are also CaseIterable
.
The synthesized CodingKeys
structs should be made CaseIterable
.
Compiler diagnostics should suggest adding CaseIterable
to enums that conform to CodingKey
.
Encoding of keys should be performed as today.
Decoding of keys should iterate over all enum values and create a Dictionary<String, T>
(where T is the CodingKey
-conforming type) map for performing lookups.
Additional considerations
The CodingKey
protocol could be given extra methods:
func allowKeyCoding(for encoder: Encoder) -> Bool
func allowKeyCoding(for decoder: Decoder) -> Bool
that could have a default implementation returning true
This would enable users to opt out of key coding for any encoder and decoder - or for specific encoders and decoders.
How does that sound? I would like to give the implementation a shot.
Does anyone think that my pitch pitch should be made into a pitch? 