Although we’ve covered a lot in these last three projects, there is one thing in particular I’d like to cover in more detail: advanced usages of Codable
. We already looked at this a little in our projects, but it's a topic that deserves some additional time as you’ll see…
Tip: If you want to know how to make SwiftData models work with Codable
, you should read this fully.
When we have JSON data that matches the way we’ve designed our types, Codable
works perfectly. In fact, we often don’t need to do anything other than add Codable
conformance – the Swift compiler will synthesize everything we need automatically.
However, a lot of the time things aren’t so straightforward, and there are three options for working with more complex data:
Generally speaking you should prefer them in that order, with option 1 being most preferable and option 3 being least.
Let's work through the first two, one at a time. I'll leave option 3 for the time being, because it's comparatively tricky!
Asking Swift to convert property names automatically is useful when our incoming JSON uses a different naming convention for its properties. For example, we might receive JSON property names in snake case (e.g. first_name
) whereas our Swift code uses property names in camel case (e.g. firstName
).
Codable
is able to translate between these two as long as it knows what to expect: we need to set a property on our decoder called keyDecodingStrategy
.
To demonstrate this, here’s a User
struct with two properties:
struct User: Codable {
var firstName: String
var lastName: String
}
That uses the naming convention typically used in Swift code, called camel case because the practice of uppercasing the first letters of words is a bit like humps on a camels back.
Now here is some JSON data with the same two properties:
let str = """
{
"first_name": "Andrew",
"last_name": "Glouberman"
}
"""
let data = Data(str.utf8)
That JSON data uses snake case, which is a naming convention where property names are written all in lowercase with words separated by underscores.
If we try to decode that JSON into a User
instance, it won’t work because the two properties have different naming styles:
do {
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: data)
print("Hi, I'm \(user.firstName) \(user.lastName)")
} catch {
print("Whoops: \(error.localizedDescription)")
}
However, if we modify the key decoding strategy before we call decode()
, we can ask Swift to convert snake case to and from camel case. So, this will succeed:
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)
print("Hi, I'm \(user.firstName) \(user.lastName)")
} catch {
print("Whoops: \(error.localizedDescription)")
}
That works great when we’re converting snake_case to and from camelCase, but what if our property names are completely different? This is where we need to rely on the second option: creating custom property name conversions.
As an example, take a look at this JSON:
let str = """
{
"first": "Andrew",
"last": "Glouberman"
}
"""
It still has the first and last name of a user, but the property names don’t match our struct at all.
When we were looking at Codable
I said that we can create an enum of coding keys that describe which keys should be encoded and decoded. At the time I said “this enum is conventionally called CodingKeys
, with an S on the end, but you can call it something else if you want,” and while that’s true it’s not the whole story.
You see, the reason we conventionally use CodingKeys
for the name is that this name has super powers: if a CodingKeys
enum exists, Swift will automatically use it to decide how to encode and decode an object for times we don’t provide custom Codable
implementations.
I realize that’s a lot to take in, so it’s best demonstrated with some code. Try changing the User
struct to this:
struct User: Codable {
enum ZZZCodingKeys: CodingKey {
case firstName
}
var firstName: String
var lastName: String
}
That code will compile just fine, because the name ZZZCodingKeys
is meaningless to Swift – it’s just a nested enum. But if you rename the enum to just CodingKeys
you’ll find the code no longer builds: we’re now instructing Swift to encode and decode just the firstName
property, which means there is no initializer that handles setting the lastName
property - and that’s not allowed.
All this matters because CodingKeys
has a second super power: when we attach raw value strings to our properties, Swift will use those for the JSON property names. That is, the case names should match our Swift property names, and the case values should match the JSON property names.
So, let’s return to our example JSON:
let str = """
{
"first": "Andrew",
"last": "Glouberman"
}
"""
That uses “first” and “last” for property names, whereas our User
struct uses firstName
and lastName
. This is a great place where CodingKeys
can come to the rescue: we don’t need to write a custom Codable
conformance, because we can just add coding keys that marry up our Swift property names to the JSON property names, like this:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
}
var firstName: String
var lastName: String
}
Now that we have specifically told Swift how to convert between JSON and Swift naming, we no longer need to use keyDecodingStrategy
– just adding that enum is enough.
So, while you do need to know how to create custom Codable
conformance, it’s generally best practice to do without it if these other options are possible.
So far you've seen how to let Swift map between snake case and camel case, and how we can specify mappings when JSON has one name and Swift uses an entirely different one.
This last option is for times when the changes are even bigger, such as if the JSON data provides a number as a string. However, it's also useful when you want to make SwiftData models conform to Codable
, as you'll see.
First, let's try some new JSON that demonstrates the problem:
let str = """
{
"first": "Andrew",
"last": "Glouberman",
"age": "13"
}
"""
As you can see, that has unhelpful names for first name and last, but also stores a number inside a string. While there's very little we can do to fix up JSON data coming from an external server, we certainly don't want its weirdness to infect our code – that's an integer, and we want it to be stored as one in our Swift code.
So, we might define a User
struct like this, so we correct the first and last name properties, and store age
as an integer:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
case age
}
var firstName: String
var lastName: String
var age: Int
}
But now we have a problem: while Swift can convert the property names for us, it can't handle different data types.
For this we need to create a completely custom Codable
implementation which means adding two things to the User
struct:
Decoder
instance and knows how to read our properties from it.encode(to:)
method that accepts an Encoder
instance and knows how to write our properties to it.Tip: Swift uses Decoder
and Encoder
here because there are lots of ways of converting data to and from Swift objects – JSON is just one of several options.
Both of these take quite a bit of code, but helpfully Xcode can sometimes help. In this case it will actually fill in all the code required to make both these work: type init
in the space below the properties, then press return with init(from decoder: Decoder)
selected, then type encode
and press return with encode(to encoder: Encoder)
selected.
Your finished User
struct should look like this:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
case age
}
var firstName: String
var lastName: String
var age: Int
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.firstName = try container.decode(String.self, forKey: .firstName)
self.lastName = try container.decode(String.self, forKey: .lastName)
self.age = try container.decode(Int.self, forKey: .age)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)
try container.encode(self.lastName, forKey: .lastName)
try container.encode(self.age, forKey: .age)
}
}
Tip: If this were a class rather than a struct, the new initializer would need to be marked with required
so that any subclasses are required to implement it.
That's a lot of code, but really only four lines matter: two from the initializer, and two from encode(to:)
.
The first line that matters is this, from the initializer:
let container = try decoder.container(keyedBy: CodingKeys.self)
That uses CodingKeys
to read all the possible keys that can be loaded from the JSON file. This looks in the CodingKeys
enum, so we can refer to things like .firstName
and .age
.
The second line that matters is this, also from the initializer:
self.firstName = try container.decode(String.self, forKey: .firstName)
That reads a string from the key .firstName
, and assigns it to the firstName
property of our struct. This part might be a bit confusing because we have firstName
twice, so let me rephrase what the line of code does: "look in the JSON to find the property matching CodingKeys.firstName
, and assign it to our local firstName
value."
This little dance matters, because CodingKeys.firstName
isn't actually called firstName
because we renamed it to match our JSON. So, in practice this line actually means "find the first
property in the JSON and assign it to firstName
in our struct" – it makes sure all the automatic renaming still happens.
If it helps, imagine reading the code like this:
self.structFirstName = try container.decode(String.self, forKey: .jsonFirstName)
That's the first two interesting lines of code. The second two are effectively inverses of the first two. These are both in the encode(to:)
method:
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)
That first line means we want to create a place where we can store all our CodingKeys
values, and the second one writes the current firstName
property to whatever is specified in CodingKeys.firstName
– again, that's important so we get the automatic renaming to first
.
At this point, chances are you're wondering how you'll ever remember this code, because it's not exactly something you can guess. So, here's my #1 tip:
When you need to implement a custom Codable
implementation and Xcode can't generate it for you, just create a new, simple struct with one property and a one-case CodingKeys
enum, have Xcode generate that, then use its implementation to help you build your own for the bigger type.
This is particularly important when working with SwiftData, where adding Codable
support means create a custom implementation. It's annoying to have to remember all the code above, and Xcode almost certainly won't help, so just create a temporary struct that Xcode can generate a Codable
implementation for, then use its structure to make your SwiftData model class Codable
.
Anyway, we got to this point because we were trying to load a string into an integer, which means making two changes to the code Xcode generated for us.
First, this line of code needs to change:
self.age = try container.decode(Int.self, forKey: .age)
That attempts to read the age
property as an integer, which will fail. Instead, we need to read it as a string, then convert that to an integer or provide a default value if conversion fails. Replace the code with this:
let stringAge = try container.decode(String.self, forKey: .age)
self.age = Int(stringAge) ?? 0
The second thing that needs to change is in encode(to:)
, so if we need to write any JSON we keep the existing format. Here, this line needs to change:
try container.encode(self.age, forKey: .age)
That writes an integer, but it needs to write a string like this:
try container.encode(String(self.age), forKey: .age)
I know creating the custom implementation seems like a lot of hassle, but as you can see it gives us exact control over what happens: we can add any kind of logic to our loading and saving, changing names, changing types, providing default values, and more.
SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.