https://www.swiftbysundell.com/articles/handling-model-variants-in-swift/
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.
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.
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]
}