Swift’s implementation of enums is, without a doubt, one of the most beloved and powerful features that the language has to offer. The fact that Swift enums go way beyond simple enumerations of integer-based constants, and support things like associated values and sophisticated pattern matching, makes them a great candidate for solving many different kinds of problems.
However, there are certain kinds of enum cases that can arguably be good to avoid, as they could lead us to some tricky situations, or make our code feel less “idiomatic” that what we intended. Let’s take a look at a few such cases and how they could be refactored using some of Swift’s other language features.
As an example, let’s say that we’re working on a podcast app, and that we’ve implemented the various categories that our app supports using an enum. That enum currently contains cases for each category, as well as two somewhat special cases that’s used for podcasts that don’t have a category at all (none
), as well as a category that can be used to reference all categories at once (all
):
extension Podcast {
enum Category: String, Codable {
case none
case all
case entertainment
case technology
case news
...
}
}
Then, when implementing features such as filtering, we can use the above enum to perform pattern matching against the Category
value that the user selected within the UI (which is encapsulated within a Filter
model):
extension Podcast {
func matches(filter: Filter) -> Bool {
switch filter.category {
case .all, category:
return name.contains(filter.string)
default:
return false
}
}
}
At first glance, the above two pieces of code might look perfectly fine. But, if we think about it, the fact that we’ve currently added a specific none
case for representing the lack of a category is arguably a bit strange, given that Swift does have a built-in language feature that’s tailor-made for that purpose — optionals.
So, if we instead were to turn our Podcast
model’s category
property into an optional, then we’d get support for representing missing categories completely for free — plus we could now leverage all of the features that Swift optionals support (such as if let
statements) when dealing with such missing values:
struct Podcast {
var name: String
var category: Category?
...
}
Something that’s really interesting about the above change is that any exhaustive switch
statements that we were previously using on Podcast.Category
values will still keep working just as they did before — since it turns out that the Optional
type itself is also, in fact, an enum that uses a none
case to represent the lack of a value — meaning that code like the following function can remain completely unchanged (apart from modifying its argument to be an optional):
func title(forCategory category: Podcast.Category?) -> String {
switch category {
case .none:
return "Uncategorized"
case .all:
return "All"
case .entertainment:
return "Entertainment"
case .technology:
return "Technology"
case .news:
return "News"
...
}
}
The above works thanks to a bit of Swift compiler magic that automatically flattens optionals when they’re used in pattern matching contexts (such as switch
statements), which lets us both address cases within the Optional
type itself, as well as cases defined within our own Podcast.Category
enum, all within the same statement.
If we wanted to, we could’ve also used case nil
instead of case .none
, since those are functionally identical in the above type of situation.
Next, let’s turn our attention to our Podcast.Category
enum’s all
case, which is also a bit strange if we think about it. After all, a podcast can’t belong to all categories simultaneously, so that all
case really only makes sense within the context of filtering.
So, rather than including that case within our main Category
enum, let’s instead create a dedicated type that’s specific to the domain of filtering. That way, we can achieve a quite neat separation of concerns, and since we’re using nested types, we can have our new enum use the same Category
name, only this time it’ll be nested within our Filter
model — like this:
extension Filter {
enum Category {
case any
case uncategorized
case specific(Podcast.Category)
}
}
Worth noting is that we could’ve chosen to use the optional approach here as well, with nil
representing either any
or uncategorized
, but since there are two potential candidates in this case, we’re arguably making our intent much more clear by using dedicated cases here.
What’s really nice about the above approach is that we can now implement our entire filtering logic using Swift’s pattern matching capabilities — by switching on the filtered category and by then using where
clauses to attach additional logic to each case:
extension Podcast {
func matches(filter: Filter) -> Bool {
switch filter.category {
case .any where category != nil,
.uncategorized where category == nil,
.specific(category):
return name.contains(filter.string)
default:
return false
}
}
}
With all of the above changes in place, we can now go ahead and remove the none
and all
cases from our main Podcast.Category
enum — leaving us with a much more straightforward list of each of the categories that our app supports:
extension Podcast {
enum Category: String, Codable {
case entertainment
case technology
case news
...
}
}
When it comes to enums like Podcast.Category
, it’s incredibly common to (at some point) introduce some kind of custom
case that can be used to handle one-off cases, or to provide forward compatibility by gracefully handling cases that might be added server-side in the future.
One way to implement that would be to use a case that has an associated value — in our case a String
representing the raw value of a custom category, like this:
extension Podcast {
enum Category: Codable {
case all
case entertainment
case technology
case news
...
case custom(String)
}
}
Unfortunately, while associated values are incredibly useful in other contexts, this is not really one of them. First of all, by adding such a case, our enum can no longer be String
-backed, meaning that we’ll now have to write custom encoding and decoding code, as well as logic for converting instances to and from raw strings.
So let’s explore another approach instead, by converting our Category
enum into a RawRepresentable
struct, which once again lets us leverage Swift’s built-in logic for encoding, decoding, and handling string conversions for such types:
extension Podcast {
struct Category: RawRepresentable, Codable, Hashable {
var rawValue: String
}
}
Since we’re now free to create Category
instances from any custom string that we want, we can easily support both custom and future categories without requiring any additional code on our part. However, to make sure that our code remains backward compatible, and to make it easy to refer to any of our built-in, currently known categories — let’s also extend our new type with static APIs that’ll achieve all of those things:
extension Podcast.Category {
static var entertainment: Self {
Self(rawValue: "entertainment")
}
static var technology: Self {
Self(rawValue: "technology")
}
static var news: Self {
Self(rawValue: "news")
}
...
static func custom(_ id: String) -> Self {
Self(rawValue: id)
}
}
Although the above change did require some amount of extra code to be added, we’ve now ended up with a much more flexible setup that’s almost entirely backward compatible. In fact, the only updates that we need to make are to code that performs exhaustive switches on Category
values.
For example, the title
function that we took a look at earlier previously switched on such a value to return a title matching a given category. Since we can no longer get an exhaustive list of each Category
value at compile-time, we’d now have to use a different approach to compute those titles. In this particular case we could, for example, see this as an excellent opportunity to move those strings to a Localizable.strings
file, and then resolve our titles like this:
func title(forCategory category: Podcast.Category?) -> String {
guard let id = category?.rawValue else {
return NSLocalizedString("category-uncategorized", comment: "")
}
let key = "category-\(id)"
let string = NSLocalizedString(key, comment: "")
guard string != key else {
return key.capitalized
}
return string
}
Another option would be to resolve our localized titles within the Category
type itself, and to perhaps also add an optional title
property which would enable our server to send pre-localized titles for custom categories that our app doesn’t yet natively support.
As a quick bonus tip, one downside of the above struct-based approach is that we now have to manually define the underlying string raw values for each of our static properties, but that’s something that we could solve using Swift’s #function
keyword. Since that keyword will be automatically replaced by the name of the function (or, in our case, property) that its encapsulating function is being called from, that’ll give us the same automatic raw value mapping as when using an enum:
extension Podcast.Category {
static func autoNamed(_ rawValue: StaticString = #function) -> Self {
Self(rawValue: "\(rawValue)")
}
}
With the above utility in place, we can now simply call autoNamed()
within each of our built-in category APIs, and Swift will automatically fill in those raw values for us:
extension Podcast.Category {
static var entertainment: Self { autoNamed() }
static var technology: Self { autoNamed() }
static var news: Self { autoNamed() }
...
static func custom(_ id: String) -> Self {
Self(rawValue: id)
}
}
Worth noting, though, is that we have to be a bit careful not to rename any of the above static properties when using that #function
-based technique, since doing so will also change the underlying raw value for that property’s Category
. However, that’s also the case when using enums, and on the flip side, we’re now also preventing typos and other mistakes that can happen when defining each raw string manually.
Swift enums are awesome (in fact, I’ve written over 15 articles on that topic alone), but there are certain situations in which another language mechanism might be a better choice for what we’re looking to build, and it’s always possible that we might need to switch between several different mechanisms and approaches as our project grows and evolves.
Hopefully, this article has given you a few ideas on how those kinds of situations and problems could be solved, and if you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.
Thanks for reading!