Weekly Swift articles, podcasts and tips by John Sundell.

Handling model variants in Swift

Published on 07 Jun 2020
Basics article available: Codable

As programmers, we’re often working on apps and systems that consist of multiple parts that need to be connected one way or another — and doing so in ways that are elegant, robust, and future proof can often be easier said than done.

Especially when using highly static languages (such as Swift), it can sometimes be tricky to figure out how to model certain conditions or pieces of data in a way that both satisfies the compiler, and results in code that’s easy to work with.

This week, let’s take a look at one such situation, which involves modeling multiple variants of the same data model, and explore a few different techniques and approaches that can let us handle dynamic data in ways that still leans into Swift’s strong emphasis on type-safety.

Mixed structures

As an example, let’s say that we’re working on a cooking app that includes both videos and written recipes, and that our content is loaded from a web service that returns JSON formatted like this:

{
    "items": [
        {
            "type": "video",
            "title": "Making perfect toast",
            "imageURL": "https://image-cdn.com/toast.png",
            "url": "https://videoservice.com/toast.mp4",
            "duration": "00:12:09",
            "resolution": "720p"
        },
        {
            "type": "recipe",
            "title": "Tasty burritos",
            "imageURL": "https://image-cdn.com/burritos.png",
            "text": "Here's how to make the best burritos...",
            "ingredients": [
                "Tortillas",
                "Salsa",
                ...
            ]
        }
    ]
}

While the above way of structuring JSON responses is incredibly common, creating Swift representations that match it can prove to be quite challenging. Since we’re receiving an items array that contains both recipes and videos mixed together, we’ll need to write our model code in a way that lets us decode both of those two variants simultaneously.

One way to do that would be to create an ItemType enum that includes cases for each of our two variants, as well as a unified Item data model that contains all of the properties that we’re expecting to encounter, and an ItemCollection wrapper that we’ll be able to decode our JSON into:

enum ItemType: String, Decodable {
    case video
    case recipe
}

struct Item: Decodable {
    let type: ItemType
    var title: String
    var imageURL: URL
    var text: String?
    var url: URL?
    var duration: String?
    var resolution: String?
    var ingredients: [String]?
}

struct ItemCollection: Decodable {
    var items: [Item]
}

The reason why the above type property is a constant, while all other Item properties remain variables, is because that’s the only piece of data that we don’t want to be modified under any circumstances — since a recipe shouldn’t be able to turn into a video, and vice versa. For the other properties, we’re utilizing Swift’s value semantics by making them variables.

While the above approach lets us successfully decode our JSON, it’s quite far from ideal — since we’re forced to implement the majority of our properties as optionals, given that they’re unique to one of our two variants. Doing so will in turn require us to constantly unwrap those optionals, even within code that only deals with a single variant, such as this VideoPlayer:

class VideoPlayer {
    ...

    func playVideoItem(_ item: Item) {
        // We can't establish a compile-time guarantee that the
        // item passed to this method will, in fact, be a video.
        guard let url = item.url else {
            assertionFailure("Video item doesn't have a URL: \(item)")
            return
        }

        startPlayback(from: url)
    }
}

So let’s explore a few ways of solving the above problem, and take a look at what sort of trade-offs that each of those approaches might give us.

Complete polymorphism

Since we are, at the end of the day, attempting to model a set of polymorphic data (as our models can take on multiple forms), one approach would be to make our Swift representations of that data polymorphic as well.

To do that, we might create an Item protocol that contains all of the properties that are shared between our two variants, as well as two separate types — one for videos and one for recipes — that both conform to that new protocol:

protocol Item: Decodable {
    var type: ItemType { get }
    var title: String { get }
    var imageURL: URL { get }
}

struct Video: Item {
    var type: ItemType { .video }
    var title: String
    var imageURL: URL
    var url: URL
    var duration: String
    var resolution: String
}

struct Recipe: Item {
    var type: ItemType { .recipe }
    var title: String
    var imageURL: URL
    var text: String
    var ingredients: [String]
}

As our items are now represented by two distinct types, we probably also want to modify our ItemCollection wrapper to include separate arrays for each of those two types as well — as otherwise we’d have to constantly type cast Item values to either Video or Recipe:

struct ItemCollection: Decodable {
    var videos: [Video]
    var recipes: [Recipe]
}

However, while the above model structure might look great in theory, in practice it’ll require a bit of extra work, since our Swift code no longer matches the format of our JSON responses. That’s not a huge problem, however, as we can always do what we did in “Customizing Codable types in Swift” — and create dedicated types specifically for decoding, along with a custom Decodable implementation.

In this case, let’s reuse our Item and ItemCollection implementations from before, while renaming them to fit their new purpose — like this:

private extension ItemCollection {
    struct Encoded: Decodable {
        var items: [EncodedItem]
    }

    struct EncodedItem: Decodable {
        let type: ItemType
        var title: String
        var imageURL: URL
        var text: String?
        var url: URL?
        var duration: String?
        var resolution: String?
        var ingredients: [String]?
    }
}

We’re now almost ready to write our custom Decodable implementation — but since we’re going to need to unwrap quite a few optionals when doing that, let’s first create a small utility method that’ll make that process much simpler:

extension ItemCollection {
    struct MissingEncodedValue: Error {
        var name: String
        ...
    }

    private func unwrap<T>(_ value: T?, name: String) throws -> T {
        guard let value = value else {
            throw MissingEncodedValue(name: name)
        }

        return value
    }
}

If the above unwrap method looks familiar, it might be because it’s really similar to this extension on the Optional type itself, which has appeared in several previous articles.

With the above pieces in place, let’s now write our actual decoding code. We’ll start by decoding an instance of our new Encoded wrapper, and we’ll then convert its items into Video and Recipe values, like this:

extension ItemCollection {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let collection = try container.decode(Encoded.self)

        for item in collection.items {
            switch item.type {
            case .video:
                try videos.append(Video(
                    type: item.type,
                    title: item.title,
                    imageURL: item.imageURL,
                    url: unwrap(item.url, name: "url"),
                    duration: unwrap(item.duration, name: "duration"),
                    resolution: unwrap(item.resolution, name: "resolution")
                ))
            case .recipe:
                try recipes.append(Recipe(
                    type: item.type,
                    title: item.title,
                    imageURL: item.imageURL,
                    text: unwrap(item.text, name: "text"),
                    ingredients: unwrap(item.ingredients, name: "ingredients")
                ))
            }
        }
    }
}

With that final piece in place, we now have a fully type-safe representation of our JSON data, all without any non-optional optionals. However, not only did the above approach require a fair amount of decoding-specific code, we’re now also losing track of the overall order of our items (as we’re splitting them up into two arrays while decoding them).

While there are of course different ways that we could fix that, including maintaining a separate [Item] array that we could use for sorting and ordering, let’s also explore a third approach that might turn out to be a neat middle ground between our first two implementations.

Rather than treating our variants as two separate implementations that share a common interface, let’s actually treat them as variants of the same model instead. That might seem like a subtle change, but it’ll turn out to have quite a big impact on our final model structure.

To get started, let’s rename our previous Item protocol to ItemVariant instead, while also dropping its type property:

protocol ItemVariant: Decodable {
    var title: String { get }
    var imageURL: URL { get }
}

Then, let’s model our actual Item type as an enum, with one case for each of our two variants — each containing an instance of that variant’s dedicated model as an associated value:

enum Item {
    case video(Video)
    case recipe(Recipe)
}

With the above change in place, we can now heavily simplify our custom Decodable implementation — which can now take place entirely within our new Item type itself, and just involves inspecting each JSON item’s type value in order to decide which underlying type to decode:

extension Item: Decodable {
    struct InvalidTypeError: Error {
        var type: String
        ...
    }

    private enum CodingKeys: CodingKey {
        case type
    }

    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 "video":
            self = .video(try Video(from: decoder))
        case "recipe":
            self = .recipe(try Recipe(from: decoder))
        default:
            throw InvalidTypeError(type: type)
        }
    }
}

An alternative to using raw strings to represent each type would be to keep using our ItemType enum from before. However, given that we’ve now introduced an ItemVariant protocol, keeping that enum around might add some confusion, and doesn’t really give us any benefits compared to defining our strings inline within the above initializer.

Since our Item implementation is once again responsible for decoding its own instances, we can now revert our ItemCollection back to simply being a container for an array of Item values — which also lets it rely on the default implementation of Decodable, just like before:

struct ItemCollection: Decodable {
    var items: [Item]
}

While this last iteration has the benefit of letting us keep using our dedicated models, while also keeping our decoding code simple, and our overall item order intact, it does come with the downside of requiring us to unpack each Item value before using it — for example by using a switch statement, like this:

extension ItemCollection {
    func allTitles() -> [String] {
        items.map { item in
            switch item {
            case .video(let video):
                return video.title
            case .recipe(let recipe):
                return recipe.title
            }
        }
    }
}

While we’re going to have to keep writing code like the above whenever we need to access data that’s specific to either Recipe or Video (which is arguably a good thing, since that “forces” us to handle both of those two possible cases), there is something we can do to give us direct access to any property defined within our ItemVariant protocol — and that’s to use Swift’s dynamic member lookup feature.

Doing that first involves adding the @dynamicMemberLookup attribute to our main Item declaration:

@dynamicMemberLookup
enum Item {
    case video(Video)
    case recipe(Recipe)
}

The second part of the puzzle is to implement a subscript that resolves a value for a given dynamic member. While doing so initially required such a subscript to accept any arbitrary string as input, since Swift 5.1 it’s possible to instead use key paths — which lets us add support for dynamic members in a fully type-safe manner, like this:

extension Item {
    subscript<T>(dynamicMember keyPath: KeyPath<ItemVariant, T>) -> T {
        switch self {
        case .video(let video):
            return video[keyPath: keyPath]
        case .recipe(let recipe):
            return recipe[keyPath: keyPath]
        }
    }
}

With the above in place, we can now access any property that’s shared between Video and Recipe (through our ItemVariant protocol) as if that property was defined within our Item type itself. Combine that with the fact that key paths can now be converted into functions (yes, I love key paths), and we can transform our allTitles method from before to now simply look like this instead:

extension ItemCollection {
    func allTitles() -> [String] {
        items.map(\.title)
    }
}

Really cool! Using the above setup we can sort of achieve the best of both worlds, in that we now get direct access to all of the properties that both of our variants support — while also being able to use our specialized Video and Recipe models when we want to write code that’s specific to any of those two variants.

Conclusion

While it can occasionally be difficult to cleanly represent dynamic or polymorphic data in Swift, there are often ways to make that happen — although finding the right structure within each given situation might require us to try out a few different approaches, just like we did in this article.

Although that sort of “trial and error” might take some extra time, going through that process is often a good investment to make when it comes to model code, as an app’s data models tend to make up the very foundation of its overall code base.

Of course, the very best would arguably be for our serialized data to always match the format that we expect within our Swift code, but that’s not always possible — especially when working on a product that ships on multiple platforms.

What do you think? How do you typically structure models that have multiple variants, and would any of the techniques covered in this article be useful within your code base? Let me know — along with your questions, comments and feedback — either via Twitter or email.

Thanks for reading! 🚀