14 Mar 2019
It’s not uncommon that we need to model a dataset composed of a finite number of discrete data types, where each data type has it’s own unique data model. This is often the case for table view data sources - they’re often comprised of a few different cell types, with each cell type having a different set of properties that it needs to be configured with.
Let’s consider a social networking feed, comprised of text posts, image posts, and article posts. We want only our image posts to have a UIImage
associated with them, and only our article posts to have a URL
associated with them.
We could model this with an enum:
enum Post {
case text(String)
case image(String, UIImage)
case article(String, URL)
}
The enum feels like a reasonably good fit here because it enforces exhaustiveness. The compiler will force us to consider all of the cases of the enum, and let us know if we missed one.
Associated values also help us tie additional information to each case, and only that case. You have to switch on the enum to extract the associated information, and you only get an image
with the image case, and an article
with the article case.
We could simply pass that information to a set of table view cells (one for each post type) to create a feed:
func cellForRow(at indexPath: IndexPath) -> UITableViewCell {
let post = posts[indexPath.row]
switch post {
case let .post(text):
let cell = tableView.dequeueCell(“TextCell”)
cell.configure(text)
return cell
case let .image(text, image):
let cell = tableView.dequeueCell(“ImageCell”)
cell.configure(text, image)
return cell
case let .article(url):
let cell = tableView.dequeueCell(“ArticleCell”)
cell.configure(text, url)
return cell
}
}
There’s a few problems with this design though. The first is that is doesn’t scale. You can look forward to this future:
case post(text, url, headline: String, subtitle: String, sharedVia: User?, originalComment: String?, promoted: Bool)
Another issue with relying heavily on enums with associated values is that they’re a pretty unstructured way to organise information. What if you have a view of your timeline that just shows all of the media, without text? You’ll soon end up in underscore hell with this type of thing:
switch Post {
case .post(_, let image, _, _, _, _):
//... Show the image cell
}
Just think, every time you added one of those new values you would have to go through your whole codebase, adding yet another underscore to all of your switch statements!
Another serious flaw of the enum
strategy is that we can’t represent a single case outside the context of the whole enum
type. For instance, if we created an ImagePostComposer
, we wouldn’t be able to just return the information for an image post. We’d instead have to return a Post
, and then switch on that, leaving us unsure of how to handle the other cases:
let post = imagePostComposer.createPost()
switch post {
case let .image(text, image):
// Do something with the post
default:
// How should we handle the other cases?
}
Ok, so maybe an enum
isn’t the best choice after all. Let’s look at another common way to structure this type of data, using struct
`s.
We can instead model our feed as a collection of struct
s, like this:
struct TextPost {
let text: String
}
struct ImagePost {
let text: String
let image: UIImage
}
struct ArticlePost {
let text: String
let articleUrl: URL
}
This approach fixes a bunch of the issues that we had with enum
s.
switch
statements.ImagePostComposer
could return an ImagePost
.Creating table view cells looks like this:
func cellForRow(at indexPath: IndexPath) -> UITableViewCell {
let post = posts[indexPath.row]
if let textPost = post as? TextPost {
let cell = tableView.dequeueCell(“TextCell”)
cell.configure(textPost)
return cell
} else if let imagePost = post as? ImagePost {
let cell = tableView.dequeueCell(“ImageCell”)
cell.configure(imagePost)
return cell
} else if let articlePost = post as? ArticlePost {
let cell = tableView.dequeueCell(“ArticleCell”)
cell.configure(articlePost)
return cell
} else {
fatalError(“Unknown cell type”)
}
}
This approach feels a bit easier to work with than the enum
example. We no longer have to unpack each field from the enum case and pass it to the cell separately, instead we can just pass the whole post model.
So is a collection of structs
better than an enum
then? I’d say so, but whilst this is better in a some ways than the enum
, we’ve lost something too. We’re now unable to switch
over our post types. We’ve got to add that fatalError
in for the default
case. We’ve lost exhaustiveness.
This is going to be a problem if we want to add a new post type later on (for instance, a VideoPost
). We’re going to be hitting those fatalError
s a lot!
What we want is a way to represent an exhaustive set of struct
s, and have the compiler warn us when we haven’t considered all cases at compile time, rather than crashing at run time. But we can’t exhaustively switch on a collection of structs
, right? Or can we?
enum
s give us exhaustiveness. struct
s give us scalability, and the ability to pass around a single case’s data in a way that isn’t tied to other cases.
We can combine these two approaches to get the best of both worlds. We’ll create a Post
enum like we did before, and for case we’ll create an associated model struct
.
struct TextPostModel {
text: String
}
struct ImagePostModel {
text: String
image: UIImage
}
struct ArticlePostModel {
text: String
articleUrl: URL
}
enum Post {
case text(TextPostModel)
case image(ImagePostModel)
case article(ArticlePostModel)
}
Let’s see how that looks when we create our table view cells. Note that we don’t need to extract every field of each case separately this time, we can just pass the model directly to the table view cell:
func cellForRow(at indexPath: IndexPath) -> UITableViewCell {
let post = posts[indexPath.row]
switch post {
case .post(let model):
let cell = tableView.dequeueCell(“TextCell”)
cell.configure(model)
return cell
case .image(let model):
let cell = tableView.dequeueCell(“ImageCell”)
cell.configure(model)
return cell
case .article(let model):
let cell = tableView.dequeueCell(“ArticleCell”)
cell.configure(model)
return cell
}
}
Let’s look at what we’ve achieved:
ArticlePostModel
). We can pass this to objects that only deal with article posts, like our ArticleCell
, and we don’t have to unpack them in the process. Or we can return an ImagePostModel
from our ImagePostComposer
.Now we have a model for each post, we can also leverage the power of protocols to write more readable code. Remember the example from earlier about creating a feed that only showed images? We can easily build this by conforming some of our models to an ImageProvider
protocol:
// Creating a convenience to grab the models will often come in handy
extension Post {
var model {
switch self {
case .text(let model) { return model }
case .image(let model) { return model }
case .article(let model) { return model }
}
}
}
let images = posts.compactMap { ($0.model as? ImageProvider).image }
The key difference with this setup rather than the ‘enum
with associated types’ setup is that we can conform to protocols on the case level. For instance, an ’image’ post, an ’image carousel’ post, and a ‘album’ post could all adopt the ImageProvider
protocol, allowing us to easily extract all of the images from all ImageProvider
models.
enum
s provide us with exhaustiveness. Any time we need to model a finite set of types they’re a good fit. Associated values are also great for simple requirements, but they don’t scale well, and having too many leads to code that’s difficult to read.
struct
s provide a way to model each case individually outside of the context of the other types. This allows us to easily pass around the data associated with a case, and create code that only deal with that single type. Structs also give our data structure (I guess the clue’s in the name!), leading more readable and maintainable code, and the ability to adopt protocols on a case by case basis.
Recently this ‘enum of structs’ pattern has been my go to starting point for modelling this type of data. Most commonly it’s been useful for table view data sources. I’ve found it to be a good pattern for providing type safety, exhastiveness and helping manage complexity. Hopefully it might work for you too!