UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Key points

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.

Custom Codable keys

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:

  1. Asking Swift to convert property names automatically.
  2. Creating custom property name conversions.
  3. Creating completely custom encoding and decoding.

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.

Completely custom Codable implementations

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:

  1. A new initializer that accepts a Decoder instance and knows how to read our properties from it.
  2. A new 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.

Hacking with Swift is sponsored by RevenueCat

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.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.9/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.