The Swift Enumeration Case Pattern
Jonathan Lehr
|
7 min readWhen I first started working with enumerations and switch statements in Swift, I found them immediately easy to use. I also appreciated the usefulness of being able to add properties and methods to an enumeration. Very nice!
However there were a number of subtleties that eluded me initially, and took some time to fully appreciate. So after laying out some basics, I’d like to share a number of powerful capabilities I discovered along the way that might not seem obvious in the early going, or that might at first seem confusing.
The Basics
Defining a Trivial Enum
Initially, Swift enumerations seem similar to what most of us have experienced in other languages.
enum Size {
case small
case medium
case large
case extraLarge
}
The previous declaration can also be written more compactly:
enum Size {
case small, medium, large, extraLarge
}
You can also define the type of a Swift enumeration, allowing each case to be mapped to a raw value that can be a string, character, or numeric type:
enum Size: String {
case small = "S"
case medium = "M"
case large = "L"
case extraLarge = "XL"
To create an instance of a enum case, you simply refer to it. In the following example, medium
is an instance of Size
.
let shirtSize = Size.extraLarge
Note that for enumerations of type String
, Swift will automatically use case labels as raw values unless you provide explicit raw values yourself, as, for example, in the preceding declaration of Size
. The code below first prints a case’s string value, followed by its raw value.
print("Size \(mySize)")
// prints "Size extraLarge"
print("Size \(mySize.rawValue)")
// prints "Size XL"
Switch Statements
An easy and obvious way to use an enum instance is in a switch
statement:
switch (shirtSize) {
case .medium: print("Shirt is size M")
default: print("Shirt is not size M")
}
The Pattern
Since you’re probably already familiar with switch
statements from other languages, their ability to match enumeration cases may seem unsurprising. However, one subtlety is that enumeration case is generalized as a pattern in Swift.
According to The Swift Programming Language (Swift 4): Patterns , “An enumeration case pattern matches a case of an existing enumeration type. Enumeration case patterns appear in switch statement case labels and in the case conditions of if
, while
, guard
, and for-in
statements.”
In other words, you can combine the keyword case
with various logic constructs, such as if
statements. Precisely how to do that, though, may not be immediately obvious. We’ll come back to this point shortly in the upcoming section on Case Conditions.
The Swift language guide goes on to say: “If the enumeration case you’re trying to match has any associated values, the corresponding enumeration case pattern must specify a tuple pattern that contains one element for each associated value.” [emphasis added]
Associated Values
So what does it mean for an enumeration case to have an associated value? Consider the following enum
declaration:
enum Garment {
case tie
case shirt
}
You could then use the Garment
enumeration, to distinguish between shirts and ties. But, suppose you also wanted to distinguish between small shirts and large shirts? To do so, you could append a tuple declaration to the declaration of the shirt
case:
enum Garment {
case tie
case shirt(size: String)
// ^^^^^^^^^^^^^^
}
Now, initializing an instance of Garment.shirt
would look almost the same as initializing a struct:
let myShirt = Garment.shirt(size: "M")
Additional cases could define tuples for their own associated values, if needed:
enum Garment {
case tie
case shirt(size: String)
case pants(waist: Int, inseam: Int)
}
let myPants = Garment.pants(waist: 32, inseam: 34)
You could then write a switch
statement to perform pattern matching on myPants
:
switch someGarment {
case .tie: print("tie")
case .shirt: print("shirt")
case .pants: print("pants")
}
// prints "pants"
You could also specify associated values to further refine the patterns you wish to match:
let items = [
Garment.tie,
Garment.shirt(size: "S"),
Garment.shirt(size: "M"),
Garment.shirt(size: "L"),
Garment.pants(waist: 29, inseam: 32),
Garment.pants(waist: 35, inseam: 34)
]
for item in items {
switch item {
case .tie: print("tie")
case .shirt("M"): print("\(item) may fit")
case .shirt("L"): print("\(item) may fit")
case .shirt: print("\(item) won't fit")
case .pants(34, 34): print("\(item) may fit")
case .pants(35, 34): print("\(item) may fit")
case .pants: print("\(item) won't fit")
}
}
// tie
// shirt("S") won't fit
// shirt("M") may fit
// shirt("L") may fit
// pants(waist: 29, inseam: 32) won't fit
// pants(waist: 35, inseam: 34) may fit
Perhaps more importantly, you could then use the value-binding pattern to unwrap associated values with let
:
let myPants = Garment.pants(waist: 32, inseam: 34)
switch someGarment {
case .tie: print("tie")
case .shirt(let s): print("shirt, size: \(s)")
case .pants(let w, let i): print("pants, size \(w) X \(i)")
}
// pants, size 32 X 34
(For more on value-binding and the value-binding pattern, see my earlier blog post on The Tuple Pattern.)
Note that Swift allows you streamline case
statements by ‘factoring out’ any let
keywords used in value binding, allowing you to rewrite the above switch
statement more concisely:
switch someGarment {
case .tie: print("tie")
case let .shirt(s): print("shirt, size: \(s)")
case let .pants(w, i): print ("pants, size \(w) X \(i)")
}
// pants, size 32 X 34
You could also add where
clauses to further refine pattern matches:
switch someGarment {
case let .pants(w, i) where w == 32: print("inseam: \(i)")
default: print("No match")
}
// inseam: 34
Other Pattern-Matching Capabilities
To be clear, the enumeration case pattern can match a number of other kinds of patterns. While not an exhaustive list, here are a few examples:
Strings
Although from Swift’s perspective, pattern matching based on values of strings, characters, and numbers is just base behavior, the fact that it works with strings feels like a special case — and an incredibly useful one at that.
struct Song {
var title: String
var artist: String
}
let aria = Song(title: "Donna non vidi mai", artist: "Luciano Pavarotti")
switch(aria.artist) {
case "Luciano Pavarotti": print(aria)
default: print("No match")
}
// Song(title: "Donna non vidi mai", artist: "Luciano Pavarotti")
Intervals
In addition to matching numeric types such as Int or Double value, enumeration cases can match numeric intervals:
let numbers = [-1, 3, 9, 42]
for number in numbers {
switch(number) {
case ..<3: print("less than 3")
case 3: print("3")
case 4...9: print("4 through 9")
default: print("greater than 10")
}
}
// less than 3
// 3
// 4 through 9
// greater than 10
Tuples
Enumeration cases can perform pattern matching on tuples:
let dogs = [(name: "Rover", breed: "Lab", age: 2),
(name: "Spot", breed: "Beagle", age: 2),
(name: "Pugsly", breed: "Pug", age: 9),
(name: "Biff", breed: "Pug", age: 5)]
for dog in dogs {
switch (dog) {
case (_, "Lab", ...3): print("matched a young Lab named \(dog.name)")
case (_, "Pug", 8...): print("matched an older Pug named \(dog.name)")
default: print("no match for \(dog.age) year old \(dog.breed)")
}
}
// matched a young Lab named Rover
// no match for 2 year old Beagle
// matched an older Pug named Pugsly
// no match for 5 year old Pug
For more details on tuples, including pattern matching, see my blog post on The Tuple Pattern.
Type Casting
Enumeration also work with the type casting pattern, using either is
, which simply checks a value’s type, or as
, which attempts to downcast a value to the provided type:
struct Dog {
var name: String
}
let items: [Any] = [Dog(name: "Rover"), 42, 99, "Hello", (0, 0)]
for item in items {
switch(item) {
case is Dog: print("Nice doggie")
case 42 as Int: print("integer 42")
case let i as Int: print("integer \(i)")
case let s as String: print("string with value: \(s)")
default: print("something else")
}
}
// Nice doggie
// integer 42
// integer 99
// string with value: Hello
// something else
Case Conditions
In addition to pattern matching in switch
statements, Swift allows you to use the case
keyword to specify pattern matches in conditionals such as if
and guard
statements, as well as for-in
and while
loop logic. While Swift makes that easy do, the syntax sometimes confuses people.
Case Conditions in Branches
In a switch
, the keyword case
is followed by the pattern you’re interested in matching, and then a colon. For example:
let someColor = "Red"
switch someColor {
case "Red": // do something here
// ...
However, when you do pattern matching in a conditional, the case
keyword is followed by an initializer — in other words, it looks like an assignment (though it’s not).
if case "Red" = someColor {
// do something here
}
Now the example above may seem silly, because clearly you could simply have compared the strings directly, which would feel more natural syntactically:
if someColor == "Red" {
// do something here
}
But suppose you’re interested in comparing enumeration values rather than strings:
let Garment.shirt(size: "XL")
The enum
type doesn’t implement Equatable
so you can’t directly compare values with the ==
operator. You could of course overload ==
for your enum
type, but then you’d need to do that every time you declare a new enumeration if you wanted to use that approach consistently. What a hassle!
Instead, you can pattern match with an enumeration case:
let items = [
Garment.tie,
Garment.shirt(size: "M"),
Garment.shirt(size: "L"),
Garment.shirt(size: "XL"),
Garment.pants(waist: 32, inseam: 34)
]
for item in items {
if case .shirt = item { print(item) }
}
// shirt: M
// shirt: L
// shirt: XL
Here we’re printing only those items that match the .shirt
enumeration case. We could, of course, be more specific:
for item in items {
if case .shirt("XL") = item { print(item) }
}
// shirt: XL
As with switch
statements, you can use let
to bind associated values:
for item in items {
if case let .shirt(size) = item, size.contains("L") {
print(item)
}
}
// shirt: L
// shirt: XL
But suppose you’re interested in comparing enumeration values rather than strings:
let Garment.shirt(size: "XL")
Case Conditions in Loops
Note that you can use pattern matching with case
directly in loop logic. For example, you could roughly approximate the two previous examples with the following, more streamlined code:
for case .shirt("XL") in items {
print("shirt, size XL")
}
// shirt, size XL
for case let .shirt(size) in items where size.contains("L") {
print("shirt, size \(size)")
}
// shirt, size L
// shirt, size XL
The great thing is that the latter examples are not only shorter, but arguably more expressive than their former counterparts. Here’s another example:
let items:[Garment] = [
.tie,
.shirt(size: "L"),
.pants(waist: 35, inseam: 31),
.pants(waist: 35, inseam: 34),
.pants(waist: 35, inseam: 35),
]
for case let .pants(w, i) in items where w == 35 && 34...36 ~= i {
print("pants, \(w) X \(i)")
}
// pants, 35 X 34
// pants, 35 X 35
Here the enumeration case matches .pants
instances, filtering out shirts and ties. The where
clause matches only pants with waist size 35 and inseam between 34 and 36, using the pattern matching operator, ~=
to compare the inseam value to an integer range.
Conclusion
Swift’s enumeration case pattern is surprisingly flexible, allowing you to use it creatively in combination with various other patterns, such as the tuple pattern and the value-binding pattern. Many of us haven’t experienced that kind of flexibility in the languages we regularly use. It can take some time to become aware of all the capabilities, and to remember to use them in the heat of battle.
But once you start getting in the habit, enumeration case and other Swift patterns will allow you to write more concise and expressive control logic. And that in turn should lead to code that is both easier to write, and — more importantly — easier to read.