Today I want to talk about a tiny and often forgotten shortcoming of using switch defaults or direct comparisons when dealing with enums, and how it can be avoided to save your life (and maybe others’?) in the future. Join me on this short and greatly exagerated tangent.
# Enums
If you know the basics of swift development, you will have come across an enum at some point, and if you haven’t please start with the official documentation. In short, they allow us to codify a set of options (cases) as a data type. Let’s take this literally in code form as a visual example, we have an enumeration of Options: A, B, and C.
enum Option {
case A
case B
case C
}
It is fairly common to want to determine which case a variable of type Option matches, for instance using a function:
func isA(_ option: Option) -> Bool {
switch option {
case .A:
true
default: // includes .B, and .C, which we don't care about
false
}
}
or using direct comparison option == .A, both of which are perfectly fine on their own. However, if we add a new ambiguous option A_and_B at a later point in time… everything will still work just fine. Well, sort of.
# The dangers of defaults
You see, the compiler will still be perfectly happy, as all cases are being handled inside the switch, and both members of the == direct comparison are still of the same type. The problem is, we have no way of identifying if they still make sense after we introduce the A_and_B option. Put another way, since everything will still compile just fine, the compiler won’t warn us that maybe we should revisit those snippets and make sure that they still make sense.
enum Option {
case A
case A_and_B // 👈
case B
case C
var isA {
switch self {
case .A:
true
default: // includes .B, .C, but also... .A_and_B ⚠️
false
}
}
}
In our example scenario above, our isA: Bool computed variable might no longer be adequate, as A_and_B is also partly A. Wouldn’t it be great if the compiler highlighted that for us? If it actively told us where this enum is being used, and deferred to us on how the new case should be handled? I’m happy to say, there is, and it’s real easy too! Introducing exhaustive switches:
- step one - avoid using the
defaultcase in switch statements. Be exhaustive and list out every case (Xcode already suggest this in the autocomplete, so no excuses there) - step two - replace in-place direct comparisons (
==) with computed variables or function calls to exhasutive switch cases - step three - rejoice, there are no more steps.
So applying that to our initial example, we should have replaced default with case .B, .C:. Then, when we introduced the A_and_B case in the enumeration’s definition, we would get a compilation error
var isA {
switch self { // ❌ error: switch must be exhaustive, missing `.A_and_B` case.
case .A:
true
case .B, .C:
false
}
}
and we can easily assess how to evaluate the new A_and_B case.
var isA {
switch self {
case .A, .A_and_B: // 👈 it fits here
true
case .B, .C:
false
}
}
# Everything is better with icecream
Now, you might be wondering “Ok aclima, this is a cool trick and all, but how can it save lives?”. Well, I’m glad you asked! Let’s create a scenario where we’re running an icecream shop. There’s a fair few flavours on our menu, but we need to be careful about people’s allergies.
enum Flavour {
case chocolate
case vanilla
case peanutButter
}
enum Allergen {
case chocolate
case nuts
}
func does(
flavour: Flavour,
contain allergen: Allergen
) -> Bool {
switch (flavour, allergen) {
case (.chocolate, .chocolate):
true
case (.peanutButter, .nuts):
true
default: // includes (.chocolate, .nuts), (.peanutButter, .chocolate), (.vanilla, chocolate), (.vanilla, nuts)
false
}
}
One day, we decide to add a new stracciatella flavour and don’t give it a second thought…
enum Flavour {
case chocolate
case stracciatella // 👈
case vanilla
case peanutButter
}
func does(
flavour: Flavour, // .stracciatella
contain allergen: Allergen // .chocolate
) -> Bool {
switch (flavour, allergen) {
case (.chocolate, .chocolate):
true
case (.peanutButter, .nuts):
true
default: // contains (.stracciatella, .chocolate) ⚠️
false
}
}
Oops, the code let you accidentally serve stracciatella to someone that has a severe chocolate allergy 😨 The good news is, you now know how to fix this and potentially save lives!
func does(
flavour: Flavour, // .stracciatella
contain allergen: Allergen // .chocolate
) -> Bool {
switch (flavour, allergen) {
case (.chocolate, .chocolate),
(.stracciatella, .chocolate), // 👈 crisis averted
(.peanutButter, .nuts):
true
case (.chocolate, .nuts),
(.peanutButter, .chocolate),
(.vanilla, chocolate),
(.vanilla, nuts):
false
}
}
# Final thoughts
When dealing with enums, although the alternative to using default or direct comparisons (==) is more verbose, leveraging the compiler to help us maintain our code is one of Swift’s greatest strengths. In this case, we trade extra verbosity for no longer needing to proactively search where and how an enum’s cases are being used, the compiler will do it for us. I’d consider that, a life saver 🥁