Swift’s Codable and Stringly-Typed JSON Objects

So. Let’s say you’re in charge of making an iPhone app and a wearable device that work together to track your workouts and share them on social media.

And let’s say you expect to the app and the device to send, and receive, respectively, a fixed set of JSON commands with very different parameters in their payloads.

Each command will have special key that lets us know the command type we’re dealing with. (This key is commonly something like type.) But beyond that, the structure of these various command types will be quite unrelated.

For example, here’s a hypothetical “Start Workout” command, which, in addition to command_type, has three additional fields:

{
    "command_type": "start_workout",
    "location": "Gary's Gym",
    "date": "2020-03-16 19:45:13 +0000",
    "intensityLevel": 5
}

And here’s an “End Workout” command, which has no extra info:

{
    "command_type": "end_workout"
}

And here’s a “Share Workout” command, which has one additional field:

{
    "command_type": "share_workout",
    "service": "twitter"
}

The challenge here is that you don’t know the type of command to parse from the JSON until you’ve read a string from a previously agreed-upon key. (In this example, that key is command_type.) This string completely determines which other fields (if any) to expect–and, more broadly, what type of command you are dealing with.

It’s not such an uncommon scenario. You might also imagine, say, a push notification whose payload contains a key describing the event that triggered the push (e.g. "push_type": "account_updated") and several other key-value pairs that are totally specific to that push trigger.

How can we used Swift to simplify the task of encoding and decoding these “stringly-typed” JSON commands in a type-safe way?

Obviously, the Codable protocol is a handy choice here. Used with the JSONEncoder and JSONDecoder types, we’ll get a lot of the encoding and decoding implementation for free.

But in this case, because the object we’re trying to represent — let’s call it a Command — takes many heterogenous forms, there’s some additional complexity.

Of course, we could always just create a single type, conforming to Codable, that includes all of the properties of all of the command types. For example:

struct Command: Codable {
    let commandType: CommandType
    let location: String?
    let date: String?
    let intensityLevel: Int?
    let service: Service?
}

This doesn’t feel so great, though, if only because we’d be forced to make all of these properties Optional, since any given command type might only use a small subset.

If, instead, we made a totally separate type, conforming to Codable, for every command type, this solves the problem of unused properties. But in this arrangement, we’d need to look into each JSON object in advance, inspecting the command_type key, before deciding which of these unrelated types to pass into JSONDecoder.decode(_:from:).

Alternatively, we could make several classes that descend from a common Codable ancestor — and I’ve seen some good implementations of this inheritance-based setup, including one here. This makes a lot of sense if the various types share certain properties in common.

With that approach, there is one disadvantage: we wouldn’t be able to exhaustively switch through the resulting subclasses, which means we might forget to handle new command types as they are added. (Unlike in Kotlin, Swift doesn’t have a concept of “sealed classes,” and so the compiler can’t check to make sure we’ve exhaustively handled every possible subclass.)

For this exercise, we’d really like command parsing to look like this:

 
    do {
        let command = try JSONDecoder().decode(Command.self, from: data)
        switch command {
            case .startWorkout(let workout):
                print("Starting workout at \(workout.location)")
            case .endWorkout:
                print("Ending workout")
            case .shareWorkout(let service):
                print("Sharing workout to \(service)")
        }
    } catch {
        // Handle the error
    }

In this approach, we’d like to make a single call to JSONDecoder.decode(_:from:), and then switch on all of the possible cases to extract the specific, fully-typed payload for each case. (There’s no need for a default branch here; if the command type is unrecognized, we can handle that in the catch block.)

We can make this possible by declaring Command to be an enum whose cases have associated values, each of which (if it exists) conforms to Codable.

So the overarching type becomes something like this:

enum Command {
    case startWorkout(Workout)
    case endWorkout
    case shareWorkout(to: ShareService)
}

With the associated value types looking like this:

struct Workout: Codable {
        let location: String
        let date: String
        let intensityLevel: Int
    }

    struct ShareService: Codable {
        enum Service: String, Codable {
            case facebook, instagram, twitter
        }

        let service: Service
    }

Now, we just need to write Command.encode() and Command.decode(from:) to make this happen.

Let’s start with the decoding.

First off, we’ll create a single new type conforming to CodingKey — called CommandKeys — that specifies the all-important key used to determine which kind of command we are parsing.

The second type we’ll create is CommandType, which specifies all the allowable values this key can have.

extension Command {
    enum CommandKeys: String, CodingKey {
        case commandType = "command_type"
    }

    enum CommandType: String, Codable {
        case start = "start_workout"
        case end = "end_workout"
        case share = "share_workout"
    }
}

With that preparation, all we need to do is implement init(from:), which does the actual parsing. Here’s the whole thing:

extension Command: Decodable {
    enum CommandKeys: String, CodingKey {
        case commandType = "command_type"
    }

    enum CommandType: String, Codable {
        case start = "start_workout"
        case end = "end_workout"
        case share = "share_workout"
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CommandTypeKeys.self)
        let commandType = try values.decode(CommandType.self, 
                                            forKey: .commandType)
        switch commandType {
        case .start: 
            self = .startWorkout(try Workout(from: decoder))
        case .end:
            self = .endWorkout
        case .share: 
            self = .shareWorkout(to: try ShareService(from: decoder))
        }
    }
}

The first two lines are standard for any custom override of Decodable.init(from:): Get a keyed container, and start decoding values for keys — in this case, the commandType key.

At that point, we’re almost done. We just switch over the resulting enum and decode the object we need as an associated value. For example, the associated value type for the start command is a Workout — which itself is fully decodable, so we just need to call Workout(from: decoder).

Encoding is equally easy. We start be encoding the all-important commandType key, and finish by encoding the entire associated value (if there is one).

extension Command: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CommandKeys.self)
        switch self {
        case .startWorkout(let workoutInfo):
            try container.encode(CommandType.start, forKey: .commandType)
            try workoutInfo.encode(to: encoder)
        case .endWorkout:
            try container.encode(CommandType.end, forKey: .commandType)
        case .shareWorkout(let shareInfo):
            try container.encode(CommandType.share, forKey: .commandType)
            try shareInfo.encode(to: encoder)
        }
    }
}

(Note: After writing this up, I found this blog post that beautifully explains this same concept of coding heterogeneous JSON. The author’s example assumes each object type’s properties are gathered under an attributes property — this example shows what you might do if these properties were instead at the top level.)

Leave a Reply

Your email address will not be published. Required fields are marked *