RSSBlog

Swift: Using Enums to Write Safer Code — Part Two

Continuing on the concept of safe code, and how we can use Swift enums to achieve that, in this blog post I will try to demonstrate an application of this concept coupled with a new feature of Swift 4, which allows the encoding and decoding of JSON objects with almost zero glue code – because let's be honest: we all hate writing glue code.

Read the Part One of this post: Swift: Using Enums to Write Safer Code


Swift 4 introduces a new pair of protocols called Encodable and Decodable, and you can conform to both at once by conforming to Codable – no, literally. That's how it is defined:

public typealias Codable = Decodable & Encodable

"But what's that for?" – you might ask – "Aren't there several JSON parsers available in Swift? Why add a new one??"

And that would be a very good question! Thankfully, the Swift contributors really outdid themselves and came up with something truly unique: no glue code required!

But what does that mean in practice? Let's use an example:

The Example

Let's suppose we want to parse a simple public API that gives you some weather information formatted in JSON. When you perform the request, the server gives you this response:

{
    "location": "Barcelona",
    "unit": "c",
    "temperature": 18
}

That's a pretty simple and straightforward object. No catches, and all values seem to have consistent types. Yet, parsing that object using the old JSONSerialization class would require many lines of code, especially glue code bridging the JSON types to the Swift types. We will create a Weather struct that contains members for each necessary object in the JSON, and an initializer that takes the JSON as Data and tries to build the object:

struct Weather
{
    let location: String
    let unit: Unit
    let temp: Int

    enum Unit: String
    {
        case celcius = "c"
        case fahrenheit = "f"
    }

    init?(data: Data)
    {
        do
        {
            guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any] else
            {
                print("JSON has unexpected format!")
                return nil
            }

            if let location = json["location"] as? String,
                let unitString = json["unit"] as? String,
                let unit = Unit(rawValue: unitString),
                let temp = json["temp"] as? Int
            {
                self.location = location
                self.unit = unit
                self.temp = temp
            }
            else
            {
                return nil
            }
        }
        catch let error
        {
            print("Failed parsing JSON: \(error)")
            return nil
        }
    }
}

This certainly works, but it is far from pretty, considering it is a struct with three members, and a JSON with three objects as well, but 43 lines of code. Enters Swift 4.

Enters Swift 4 — Encodable & Decodable

With the ✨magic✨ of Swift 4, we are able to reduce the piece of code above to this:

struct Weather: Codable
{
    let location: String
    let unit: Unit
    let temp: Int

    enum Unit: String, Codable
    {
        case celcius = "c"
        case fahrenheit = "f"
    }

    init?(data: Data)
    {
        do
        {
            self = try JSONDecoder().decode(Weather.self, from: data)
        }
        catch let error
        {
            print("Failed parsing JSON: \(error)")
            return nil
        }
    }
}

Time is money! 🤑 Lines of code too! This example now contains just 25 lines of code. Pretty impressive, no?

"But wait." — you might say – "What the heck is going on here??"

Indeed, a heck of a lot is going on behind the scenes.

How does it work??

One of the main features of Swift is the usage of "inferred types to make code cleaner and less prone to mistakes".1

In line with this, features in Swift are designed with the purpose of making use of type-inference as much as possible. If you look at the members definition in our Weather struct, we already tell Swift which types each member should have:

let location: String
let unit: Unit
let temp: Int

So why define this information again, while parsing the JSON? Let the compiler do that for us! As defined by the Decodable documentation, a type that conforms to this protocol is "A type that can decode itself from an external representation".2

By using metadata of the conforming types, decoder objects, such as JSONDecoder, are able to synthesize an object of the conforming type automatically. What this means for the programmer, is that unless they need a specific customized encoding/decoding behavior, there will be no need of defining any glue code to make de decoder work.

Furthermore, by defining the Unit enum as having a rawType of String, and setting their values to their JSON value equivalent, the Unit type is also automatically decoded.

However, many JSON objects might have heterogeneous structures, especially when dealing with older, or just bad APIs; simply giving JSONDecoder the desired type might fail to produce the desired object. Let's analyze this in the next example.

Heterogeneous JSON structure

Suppose we are implementing an application that parses a feed encoded as a JSON, and the structure of each element in the feed "array" might vary depending on the "type" of the feed entry. There are three feed types:

  • A "post" type, which as a "created" date, an "author" name, a "content" string, and a "title" string.
  • An "announcement" type, which has "title" string, and a "creation" date.
  • A "link" type, which has a "created" date, a "title" string, and a "url" value.

Because the feed was created with a lazy parser in mind (decoding objects one by one and detecting the "type" on the spot), the elements present in each element will vary. Take this example JSON:

{
    "last_update": 1507594438,
    "feed" : [
        {
            "type": "announcement",
            "title": "New Feed App available!",
            "created": 1507459515
        },
        {
            "type": "post",
            "author": "John Appleseed",
            "title": "New Beginnings",
            "content": "Magna labore velit anim deserunt in nisi eiusmod veniam nulla dolor sit laborum.",
            "created": 1507439415
        },
        {
            "type": "link",
            "url": "https://www.brunophilipe.com",
            "title": "Bruno's Homepage",
            "created": 1504439415
        }
    ]
}

As you can see, the feed does not have a homogeneous structure, which means we will need to customize our Decodable declaration to make it work. Thankfully, this does not mean we need a lot of extra code!

If we just define the structure of our Feed object, it would look like this:

struct Feed
{
    let lastUpdated: Int
    let feed: [FeedEntry]

    enum FeedEntry
    {
        case announcement(Announcement)
        case post(Post)
        case link(Link)

        struct Announcement
        {
            let title: String
            let created: Int
        }

        struct Post
        {
            let author: String
            let title: String
            let content: String
            let created: Int
        }

        struct Link
        {
            let url: URL
            let title: String
            let created: Int
        }
    }
}

However, if you try simply conforming the Feed struct to Decodable (and all its internal types), the compiler will complain, because it can't deduce how to parse this structure. We will do this by adding a few lines of code.

The first customization is defining a CodingKeys enum subtype in the Feed struct. That enum is looked up by the JSONDecoder, and is used to inform it of which keys will be used for decoding the object. If you don't define a CodingKeys enum in your type, it will be inferred automatically for you, making your code shorter.

But in our case, we need it, because we have to list which JSON keys are required for our Feed object, and optionally an alternative string value from the member name. For instance: I prefer using cameCase in my code, therefore I called the "last updated" member lastUpdated, but the JSON key for that value is last_updated. We use the CodingKeys to bind the JSON key name to the desired member in our Feed object. The feed member uses the same name feed in the JSON, so we don't need to customize it. All we do is list it in the CodingKeys enum:

struct Feed: Decodable
{
    ...
    enum CodingKeys: String, CodingKey
    {
        case lastUpdated = "last_updated"
        case feed
    }
}

The next customization is adding the init(from decoder: Decoder) initializer to FeedEntry. Because that enum has a heterogeneous structure, we need to bind the JSON to the FeedEntry objects manually. However, we can make that easier by using the homogeneous subtypes (those are Announcement, Post, and Link). Their parsing will work automatically, so we take advantage of that by attaching each of those subtypes to the respective case.

init(from decoder: Decoder) throws
{
    let container = try decoder.container(keyedBy: CodingKeys.self)
    // `type` will contain the `type` value of the JSON feed entry
    let type = try container.decode(String.self, forKey: .type)
    ...
}

We make use of the CodingKeys again to define the JSON key of the type JSON value, as that is the authority which defines which entry type we will try to parse. Notice there is no type member in FeedEntry, but that's not a problem, because once the parsing is complete, we will have enums to tell us which is the type of the entry, which is a lot safer than a string value, and a lot safer too! It's a win-win.

enum FeedEntry: Decodable
{
    ...
    enum CodingKeys: String, CodingKey
    {
        case type
    }
}

Then we simply switch over the known type values for the entry ("announcement", "post", and "link"). If a known value is found, we try to instantiate the correct enum case and attach the respective subtype (Announcement, Post, and Link), else if an unknown value for type is found, we throw an error.

switch type
{
// We just tell each subtype to try to decode itself from the decoder object
case "announcement":    self = .announcement(try Announcement(from: decoder))
case "post":            self = .post(try Post(from: decoder))
case "link":            self = .link(try Link(from: decoder))
default:
    throw DecodingError.dataCorruptedError(forKey: CodingKeys.type, in: container,
                                           debugDescription: "Unsupported type: \(type)")
}

The final Feed declaration will look like this:

struct Feed: Decodable
{
    let lastUpdated: Int
    let feed: [FeedEntry]

    enum FeedEntry: Decodable
    {
        init(from decoder: Decoder) throws
        {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let type = try container.decode(String.self, forKey: .type)
            switch type
            {
            case "announcement": self = .announcement(try Announcement(from: decoder))
            case "post": self = .post(try Post(from: decoder))
            case "link": self = .link(try Link(from: decoder))
            default:
                throw DecodingError.dataCorruptedError(forKey: CodingKeys.type,
                                                       in: container,
                                                       debugDescription: "Unsupported type: \(type)")
            }
        }

        case announcement(Announcement)
        case post(Post)
        case link(Link)

        struct Announcement: Decodable
        {
            let title: String
            let created: Int
        }

        struct Post: Decodable
        {
            let author: String
            let title: String
            let content: String
            let created: Int
        }

        struct Link: Decodable
        {
            let url: URL
            let title: String
            let created: Int
        }

        enum CodingKeys: String, CodingKey
        {
            case type
        }
    }

    enum CodingKeys: String, CodingKey
    {
        case lastUpdated = "last_updated"
        case feed
    }

    init?(data: Data)
    {
        do
        {
            self = try JSONDecoder().decode(Feed.self, from: data)
        }
        catch let error
        {
            print("Failed parsing JSON: \(error)")
            return nil
        }
    }
}

This code will not only parse the example JSON specified above, but it will also enforce the defined types automatically. This will make the code safer and more reliable, because you will be able to pinpoint exactly what is wrong with the JSON structure without having to manually step over the parser, object by object:

Example errors (a bit too verbose, but this will probably improve over time):

Type mismatch:

Failed parsing JSON: typeMismatch(Swift.Int, Swift.DecodingError.Context(codingPath:
[__lldb_expr_73.Feed.CodingKeys.feed, Foundation.(_JSONKey in _12768CA107A31EF2DCE034FD75B541C9)(stringValue:
"Index 2", intValue: Optional(2)), __lldb_expr_73.Feed.FeedEntry.Link.(CodingKeys in
_DBB86F2970A51C879D39C5C6BE5D970C).created], debugDescription: "Expected to decode Int but found a string/
data instead.", underlyingError: nil))

Translates to:
Feed.FeedEntry.Link.created: Expected to decode Int but found a string/ data instead.

Key not found:

Failed parsing JSON: keyNotFound(__lldb_expr_79.Feed.FeedEntry.Post.(CodingKeys in
_2BD1F2F97FE8D5D38A752C7426E71000).author, Swift.DecodingError.Context(codingPath: 
[__lldb_expr_79.Feed.CodingKeys.feed, Foundation.(_JSONKey in _12768CA107A31EF2DCE034FD75B541C9)(stringValue: 
Index 1", intValue: Optional(1))], debugDescription: "No value associated with key author (\"author\").",
underlyingError: nil))

Translates to:
Feed.FeedEntry.Post: No value associated with key author ("author").

Unknown type:

Failed parsing JSON: dataCorrupted(Swift.DecodingError.Context(codingPath: 
[__lldb_expr_83.Feed.CodingKeys.feed, Foundation.(_JSONKey in _12768CA107A31EF2DCE034FD75B541C9)(stringValue: 
Index 0", intValue: Optional(0)), __lldb_expr_83.Feed.FeedEntry.CodingKeys.type], debugDescription:
"Unsupported type: image", underlyingError: nil))

Translates to:
Feed.FeedEntry.CodingKeys.type: Unsupported type: image.

From there, we are able to go directly to the correct failure point and see what went wrong.

Conclusion

I hope this gives at least a small hint on how you can use the safety of enums to improve the parsing of your JSONs when you need to do so. I've developed the ideas above from my own experience parsing heterogeneous JSON objects, and I think this is a solution that balances concise code and strong typing.

Apple describes the functionality of Encodable an Decodable, and how you can customize if further in their own article, which I recommend.

Keep in mind:

As difficult as it is to write safe code, applications that break silently (or cryptically) are very difficult to use, debug, and fix; especially when the failure is in an upstream library. Writing safe code that breaks in very specific points, and with very specific causes, helps everyone involved: the user when reporting the problem, and the programmer when fixing it.

We can't expect to write correct code 100% of the time, but we can force ourselves to use safer conventions and practices that will increase the chances that our shortcomings will not be missed, and that our code will be correct. Combined with other practices such as unit testing, I believe this convention has the potential of being bullet-proof.

Thanks for reading, and happy coding!