Pattern matching in Swift
One really elegant aspect of Swift’s design is how it manages to hide much of its power and complexity behind much simpler programming constructs. Take something like a for
loop, or a switch
statement — on the surface, both work much the same way in Swift as they do in other languages — but dive a few levels deeper and it turns out that they’re much more powerful than what it first might seem like.
Pattern matching is one source of that additional power, especially considering just how integrated it is into many different aspects of the language. This week, let’s take a look at a few of those aspects — and how pattern matching unlocks coding styles that can prove to be both really convenient and quite elegant.
Iterative patterns
Let’s say that we’re building a messaging app, and that we’re working on a function that iterates over all messages in a list and deletes the ones that have been marked by the user. Currently, we’ve implemented that iteration using a standard for
loop with a nested if
statement, like this:
func deleteMarkedMessages() {
for message in messages {
if message.isMarked {
database.delete(message)
}
}
}
The above definitely works — but the argument could be made that if we were to implement it using a more declarative style, then we’d end up with a more elegant solution. One way to do that would be to instead use a more functional approach, and first filter
our array of messages to only include the ones that were marked, and then apply the database.delete
function to each element in the filtered collection:
func deleteMarkedMessages() {
messages.filter { $0.isMarked }
.forEach(database.delete)
}
To learn more about the above style of programming — check out “First class functions in Swift” and “Transforming collections in Swift”.
Again, we have perfectly valid code that does the job, but — depending on our team’s preferences and familiarity with functional programming — the second solution might look a bit more complex. From a performance perspective, it also requires us to make two passes through the array (one for filtering and one for applying the delete function) rather than just a single one — like in the original implementation.
While there are ways to optimize the second implementation (for example by using function composition or lazy collections, both of which we’ll take a closer look at in upcoming articles) — it turns out that pattern matching can let us strike a quite nice balance between the two.
Using the where
clause, we can attach a pattern to match against directly to our original for
loop, making it possible to get rid of that nested if
statement — and both make our implementation more declarative, and much simpler — like this:
func deleteMarkedMessages() {
for message in messages where message.isMarked {
database.delete(message)
}
}
And that’s just the tip of the iceberg. Not only can a for
loop match against a pattern using a where
clause, it can also do so within its own element definition.
For example, let’s say that we’re working on a game that includes some form of multiplayer match-making component. To model a match, we use a struct that both contains the start date of the match, and an array of optional Player
values — where nil
means that a seat is still open for a player to be matched against:
struct Match {
var startDate: Date
var players: [Player?]
}
Now let’s say that we want to render a list of all players that are currently in a match, excluding any empty seats. To do that, we’d need to iterate through the players
array and discard all nil
values — which could either be done by transforming the array using compactMap
, or using a nested if
statement (just like before) — but thanks to pattern matching, it can also be done using this handy for case let
syntax:
func makePlayerListView(for players: [Player?]) -> UIView {
let view = PlayerListView()
for case let player? in players {
view.addEntryForPlayer(named: player.name,
image: player.image)
}
return view
}
The above may look a bit alien at first, especially since many Swift developers might be used to only seeing the case
keyword in switch
statements or enum declarations. But if we think about it, the above actually follows the same kind of logic — since all optionals are actually values of the Optional<Wrapped>
enum under the hood, and since Swift’s pattern matching engine isn’t exclusive to switch
statements.
Switching on optionals
Continuing on the topic of optionals — when modelling state, it’s very common to use an enum to represent each distinct state that an object or operation can be in. For example, we might use the following enum to represent the state of loading some form of data:
enum LoadingState {
case none
case loading
case failed(Error)
}
However, our above enum currently includes a case called none
, which isn’t really a loading state at all — but rather a lack of such a state. And since we already have a built-in way to represent the lack of a value in Swift — optionals — having a custom state for none
feels a bit redundant. So let’s reduce our LoadingState
enum to instead look like this:
enum LoadingState {
case loading
case failed(Error)
}
With the above change in place, we’ll now instead use LoadingState?
when we want to represent an optional loading state — which seems like a perfect fit, but initially it might seem like that might make handling such a value a bit harder, since we’d have to first unwrap that optional and then switch on it.
Thankfully, Swift’s pattern matching capabilities again come to the rescue, since just like how we used a question mark after player
when iterating over an array of optionals, we can also put question marks after each enum case in a switch
statement to be able to handle both nil
and actual values in one go — like this:
extension ContentViewController: ViewModelDelegate {
func viewModel(_ viewModel: ViewModel,
loadingStateDidChangeTo state: LoadingState?) {
switch state {
case nil:
removeLoadingSpinner()
removeErrorView()
renderContent()
case .loading?:
removeErrorView()
showLoadingSpinner()
case .failed(let error)?:
removeLoadingSpinner()
showErrorView(for: error)
}
}
}
The above is not only really convenient, but also reduces the number of statements and conditionals that we need to keep track of, by turning all of our state handling code into a single switch
statement — pretty nice! 👍
Declarative error handling
Next, let’s switch gears a bit and take a look at how Swift’s pattern matching capabilities can let us implement more fine-grained error handling in a very declarative fashion. Error handling code can easily become quite complex — especially if we want to go beyond simply showing a generic view for any kind of error.
For example, let’s say that we want to handle four distinct groups of errors that could happen while performing a network request:
- Errors resulting from the user not having access to the Internet.
- The user’s access token has become invalid.
- Other network related errors.
- Any other kind of error.
Rather than having to implement the above using a series of if
and else
statements, with nested conditions and checks, we could instead make use of many different ways to match patterns — again letting us implement all of our logic within a single, declarative, switch
statement — like this:
func handle(_ error: Error) {
switch error {
// Matching against a group of offline-related errors:
case URLError.notConnectedToInternet,
URLError.networkConnectionLost,
URLError.cannotLoadFromNetwork:
showOfflineView()
// Matching against a specific error:
case let error as HTTPError where error == .unauthorized:
logOut()
// Matching against our networking error type:
case is HTTPError:
showNetworkErrorView()
// Fallback for other kinds of errors:
default:
showGenericErrorView(for: error)
}
}
Now that’s pretty awesome! 😀 However, there’s one thing that kind of “sticks out” a bit within the above example — and that’s how we need to perform type casting in the second case (when comparing against our own HTTPError
), while in the first case we can directly match against instances of Foundation’s URLError
. Why is that?
At first it may seem that Foundation’s error types are getting some form of special treatment from the compiler, but it turns out that it all comes down to how pattern matching is implemented under the hood. So, it’s time to dive even deeper!
Under the hood with custom matching
While Swift’s pattern matching is partially enabled through various specific syntax features (such as case let
), the actual logic that matches a given value against a pattern isn’t actually baked into the compiler or the language itself. Just like many other seemingly low-level features, such logic is implemented using normal Swift code, in the standard library.
Specifically, Swift uses various overloads of the ~=
operator to do pattern matching — which also lets us define our own overloads to enable more kinds of matching to take place, in the same native way as the built-in functionality.
To address the problem of HTTPError
not being matched in the same nice way as URLError
above — let’s define a pattern matcher that matches any Error
against a specific Equatable
error type — like this:
func ~=<E: Error & Equatable>(rhs: E, lhs: Error) -> Bool {
return (lhs as? E) == rhs
}
With the above in place, we can now use the same lightweight syntax to match our own custom errors, giving us this final version of our error handling switch
statement:
func handle(_ error: Error) {
switch error {
case URLError.notConnectedToInternet,
URLError.networkConnectionLost,
URLError.cannotLoadFromNetwork:
showOfflineView()
case HTTPError.unauthorized:
logOut()
case is HTTPError:
showNetworkErrorView()
default:
showGenericErrorView(for: error)
}
}
As an added bonus, since we didn’t implement our ~=
operator overload to be hard-wired for our concrete HTTPError
type, we can now use the same syntax as above to pattern match against any kind of Equatable
errors that we might use in our code base — not only in switch
statements, but for all other kinds of pattern matching as well.
Conclusion
Pattern matching is an incredibly powerful feature that not only gives us more options in terms of the syntax we use to implement things like loops, conditionals and switch
statements — but also lets us reduce the number of code paths and make our logic become more declarative, which can often lead to more robust code that’s easier to test.
While there are multiple other ways that pattern matching can be used in Swift that this article didn’t cover, it has hopefully given you some ideas as to how both the built-in pattern matching capabilities — and custom ones — can be applied in various situations. Like always, it’s important to carefully consider in which situations to apply pattern matching, and in which situations to stick with more traditional constructs — such as standard if
and else
statements.
What do you think? Do you currently make use of pattern matching in your projects, or is it something you’ll try out? Let me know — along with your questions, comments or feedback — on Twitter or by contacting me.
Thanks for reading! 🚀