Skip to main content
  1. SwiftUI in 100 Days Notes/

Day 60 - SwiftUI JSON Custom Codable Key and FriendFace Milestone Project

Custom Codable Keys #

When our JSON data matches the types we design, Codable works perfectly. In fact, it’s usually enough that we don’t do anything more than add the Codable compatibility - the Swift compiler will automatically generate everything we need.

However, often things are not that easy and we have three options for working with more complex data:

  1. Ask Swift to automatically convert property names.
  2. Create custom property name transformations.
  3. Create completely custom encoding and decoding.

In general, you should rank these options in order of preference, with option 1 being the most preferable and option 3 being the least preferable.

Let’s examine the first two options one by one. I will leave option 3 for now as it is comparatively more complex!

Asking Swift to automatically convert property names is useful when the property naming conventions in the incoming JSON are different from those of our Swift code. For example, JSON property names might be snake case (e.g. first_name), while in our Swift code we might be using camel case (e.g. firstName).

Codable can translate between these two formats, for this we need to set a property called keyDecodingStrategy.

To demonstrate this, we have a User struct with two properties:

struct User: Codable {
    var firstName: String
    var lastName: String
}

It uses the naming convention commonly used in Swift code, called “camel case” because the practice of capitalizing the first letters of words resembles the humps on the backs of camels.

Now let’s give a piece of JSON data with the same two properties:

let str = """
{
    "first_name": "Andrew",
    "last_name": "Glouberman"
}
"""

let data = Data(str.utf8)

This JSON data uses the “snake case” naming convention, where property names are all lowercase and words are separated by underscores.

If we try to decode this JSON into a User instance, it won’t work, because the two properties use 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 change the key decoding strategy before calling the decode() method, we can ask Swift to convert snake case to camel case and vice versa. Thus, the decoding will be successful:

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)")
} 

This works great for converting from snake_case to camelCase and vice versa, but what if our property names are completely different? That’s when we need the second option, which is to create custom property name conversions.

As an example, let’s look at this JSON:

let str = """
{
    "first": "Andrew",
    "last": "Glouberman"
}
"""

Here we have the user’s first and last name, but the property names don’t match our struct at all.

When we were looking at Codable, I mentioned that we could create a CodingKeys enum that defines the encode and decode keys. At the time I said “this enum is traditionally called CodingKeys, with an S at the end, but you can call it something else if you want”. But that’s not the whole story.

Actually, the reason we use the name CodingKeys is that it has special powers: If a CodingKeys enum exists, Swift automatically determines how an object should behave to be encode and decode, so we don’t have to provide special Codable implementations.

This can be a bit hard to understand, so it’s better to illustrate with a code example. Try changing the User struct like this:

struct User: Codable {
    enum ZZZCodingKeys: CodingKey {
        case firstName
    }

    var firstName: String
    var lastName: String
}

That code is compilable for now, because the name ZZZZCodingKeys is meaningless for Swift - it’s just a nested enum. But if you rename the enum to just CodingKeys, the code will no longer compile: We are now only told to encode and decode the firstName property, which means that there is no initializer that sets the lastName property - and this is unacceptable.

This is because CodingKeys has a second superpower: When we add raw value strings to popertys, Swift uses them for JSON property names. That is, case names must match our Swift property names, and case values must match JSON property names.

So, let’s go back to our example JSON:

let str = """
{
    "first": "Andrew",
    "last": "Glouberman"
}
"""

In this case the JSON property names “first” and “last” are used, while our User struct uses the firstName and lastName properties. This is where CodingKeys can help us: We don’t need to write a special Codable compatibility, because we can add coding keys to map our Swift property names to 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 don’t need to use keyDecodingStrategy anymore - just add that enum.

So, you need to know how to create custom Codable compatibility, but if these other options are possible, it’s usually best practice to use them.

Completely Custom Codable Implementation #

So far, you’ve seen how Swift can map between snake case and camel case, and how we can specify a mapping when JSON has one name and Swift uses a completely different name.

The last option is for when the changes are larger, for example when the JSON data provides a number as a string. But this is also useful when you want it, as you will see how to make SwiftData models conform to Codable.

First, let’s try a new JSON that demonstrates the problem:

let str = """
{
    "first": "Andrew",
    "last": "Glouberman",
    "age": "13"
}
"""

Here the first and last names have unhelpful names, and a number is stored in a string. We can do little to fix the problems with JSON data coming from a server, but we certainly don’t want their quirks polluting our code - it’s an integer, and we want to store it as an integer in our Swift code.

So, we can fix the firstName and lastName properties and define a User struct that will 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: Swift can convert property names for us, but it can’t handle different data types.

In this case, we need to create a completely custom Codable implementation. For this we need to add two things to the User struct:

  1. A new initializer that accepts a Decoder instance and knows how to read properties from it.
  2. A new encode(to:) method that accepts an Encoder instance and knows how to write properties there.

Tip: Swift uses Decoder and Encoder here because there are many ways to convert data into Swift objects - JSON is just one of them.

Both require quite a lot of code, but Xcode can sometimes help us. In this case, it needs to fill in all the code that will make both work: Type init under properties, then select init(from decoder: Decoder) and press Enter, then type encode and select encode(to encoder: Encoder) and press Enter.

The completed 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 was a class instead of a struct, the new initializer would have to be marked as required so that subclasses would have to implement it.

Yes, that’s a lot of code, but really only four lines are important: two lines from the init(from:) method and two lines from the encode(to:) method.

The first line that is important is this line from the initializer:

let container = try decoder.container(keyedBy: CodingKeys.self)

This code reads all possible keys that can be loaded from the JSON file using CodingKeys. CodingKeys is looked up in the enum, so things like .firstName and .age can be referenced.

This is the second important line, also from the initializer:

self.firstName = try container.decode(String.self, forKey: .firstName)

This code reads a string corresponding to the key .firstName from JSON and assigns it to the firstName property of the struct. This part can be a bit confusing because firstName appears twice, so let me rephrase what the code does: ‘Find the property corresponding to CodingKeys.firstName in JSON and assign it to our local firstName value.’

This small step is important, because CodingKeys.firstName is not actually firstName, because we renamed it to match our JSON. So, in reality, this line means ‘Find the first property in JSON and assign it to the firstName property of our structure’ - to make sure that the automatic renaming still happens.

To help, imagine that you could read the code like this:

self.structFirstName = try container.decode(String.self, forKey: .jsonFirstName)

This is the first two interesting lines of code. The second two lines effectively do the opposite of the first two. They are in the encode(to:) method:

var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)

This first line means that we want to create a place where we can store all our CodingKeys values, and the second line writes the existing firstName property to the one specified in CodingKeys.firstName - here it is important that the automatic renaming is done to first.

At this point, you’re probably wondering if you’ll ever remember this code, because it’s not predictable. So, let me give you my most important tip:

**When you need to implement a custom Codable application and Xcode can’t create it for you, create a new, simple structure with a single-feature, single-state CodingKeys enum, build your own application using that application that Xcode created.

This is especially important when working with SwiftData, because adding Codable support means creating a custom application. Remembering all the code above is tedious and Xcode certainly won’t help, so create a temporary structure for a Codable implementation that Xcode could create, then use that structure to make your SwiftData model class Codable.

Anyway, we got to this point because we tried to load a string into an integer, which required us to make two changes to the code that Xcode generates. First, this line of code needs to be changed:

self.age = try container.decode(Int.self, forKey: .age)

This tries to read the age property as an integer and fails. Instead, we need to read it as a string, then convert it to an integer, or provide a default value if the 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 be changed is the encode(to:) line, so that if we need to write any JSON we keep the current format. Here, this line needs to be changed:

try container.encode(self.age, forKey: .age)

It writes an integer, but it should write a string like this one:

try container.encode(String(self.age), forKey: .age)

I know that creating a custom implementation seems like a lot of hassle, but as you can see it gives us full control over what happens: we can add all kinds of logic to our upload and save, change names, change types, provide default values and more.

FriendFace Milestone Project #

Here it’s time to build an application from scratch, and today is a particularly comprehensive challenge: your task is to use URLSession to download some JSON from the internet, use Codable to convert them into Swift types, then use NavigationStack, List and more to show them to the user.

Your first step should be to examine the JSON. The URL you want to use is: https://www.hackingwithswift.com/samples/friendface.json - this is a large collection of randomly generated data for sample users.

As you can see, there is an array of people and each person has an ID, name, age, email address and more. They also have a set of tag arrays and a set of friends, each with a name and ID.

How much you implement this is up to you, but you should at least do the following:

  • Take the data and parse it into User and Friend structures.
  • Display a list of users with some information about them, such as their name and whether they are currently active.
  • Create a detail view that is shown when a user is tapped, offering more information about them, including the names of their friends.
  • Before you start the download, check that your User directory is empty so you don’t start the download over and over again each time the view is shown.

If you are not sure where to start, start by designing your types: Create a User structure with name, age, company and so on, then a Friend structure with id and name. After that, move on to some URLSession code to get the data and decode it into your types.

You may notice that the date each user registered has a very specific format: 2015-11-10T01:47:18-00:00. This is known as ISO-8601 and is so common that there is a built-in dateDecodingStrategy called .iso8601 that decodes it automatically.

When creating this, I want you to keep one thing in mind: this kind of implementation is a must for iOS app development - if you can do it with confidence, you are well on your way to becoming a full-time app developer.

**As always, the best way to solve this challenge is to keep it simple - write as little code as possible to solve the challenge and make sure it works well.

Solution #

The solution for this project is available on GitHub below;

https://github.com/GorkemGuray/FriendFace


You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.

This article contains the notes I took for myself from the articles found at SwiftUI Day 60. Please use the link to follow the original lesson.

Görkem Güray
Author
Görkem Güray
I am working as an Industrial Automation Engineer in a machine manufacturing company. I usually develop machine software with Omron systems.

comments powered by Disqus Mastodon