Defining custom patterns in Swift
One of the most interesting aspects of Swift as a language is just how many of its core features that are implemented using the language itself, rather than being hard-coded into the compiler. That’s not only elegant from a theoretical perspective, but also gives us a ton of practical flexibility, as it lets us tweak how the language works and behaves in really powerful ways.
An example of one such feature is pattern matching which, among other things, determines how control flow constructs like switch
and case
statements are evaluated. This week, let’s dive deep into the world of pattern matching in Swift — to take a look at how we can construct completely custom patterns, and some of the interesting techniques that we can unlock by doing so.
Building the basics
As the name implies, pattern matching is all about matching a given value against a pre-defined pattern, often in order to figure out which code branch to continue executing a program on. For example, every time we switch on a value, we are using Swift’s pattern matching feature:
func items(in section: Section) -> [Item] {
switch section {
case .featured:
return dataSource.featuredItems
case .recent:
return dataSource.recentItems
}
}
Above we’re matching a Section
enum value against two patterns that are made up of its cases (featured
and recent
), and while that’s a very common way of using pattern matching in Swift — it barely scratches the surface of what the feature is capable of.
To take things further, let’s start by defining a Pattern
struct, which we’ll use to define our own closure-based patterns. Those closures will simply take a value to match, and then return the result as a Bool
:
struct Pattern<Value> {
let closure: (Value) -> Bool
}
The above struct may be simple, but it actually enables us to now define all sorts of custom patterns, by extending it using generic type constraints in order to add static factory methods that create our patterns. For example, here’s how we could define a pattern that lets us match a value against a given set of candidates:
extension Pattern where Value: Hashable {
static func any(of candidates: Set<Value>) -> Pattern {
Pattern { candidates.contains($0) }
}
}
Before we’ll be able to use our new Pattern
struct within a switch
statement, however, we’ll also need to tell Swift how to actually evaluate it within such a context.
All forms of pattern matching in Swift are powered by the ~=
operator, which takes the pattern to evaluate as its left-hand-side argument, and the value being matched as its right-hand-side one. So in order to hook our Pattern
type into that system, all we have to do is to overload ~=
with a function that takes an instance of our new struct and a value to match — like this:
func ~=<T>(lhs: Pattern<T>, rhs: T) -> Bool {
lhs.closure(rhs)
}
With the above in place, we’ve now built all of the infrastructure required to define our own custom patterns — so let’s get started!
Mix and match
Let’s say that we’re working on some form of social networking app, that uses a LoggedInUser
struct in order to keep track of the currently logged in user’s data — for example the user’s ID, as well the IDs of the friends they’ve added using our app:
struct LoggedInUser {
let id: Identifier<User>
var friendIDs: Set<Identifier<User>>
...
}
Above we’re using the Identifier
type from “Type-safe identifiers in Swift” and the Identity package.
Now let’s say that we’re building a view controller that lets us display any number of users as a list — and that we want to render different icons depending on what kind of user that we’re displaying. That decision can now be made completely within a single switch
statement, thanks to our new Pattern
type and its any(of:)
variant:
private extension UserListViewController {
func resolveIcon(for userID: Identifier<User>) -> Icon {
switch userID {
case loggedInUser.id:
return .currentUser
case .any(of: loggedInUser.friendIDs):
return .friend
default:
return .anyUser
}
}
}
The above may at first not seem that different compared to writing our logic as a series of if
and else
statements, but it does make our code more declarative — and also makes userID
a single source of truth for all of our possible rules and outcomes.
Comparing patterns
Let’s continue extending our Pattern
type with more capabilities — this time by adding support for patterns that compare one value against another. We’ll do that by writing an extension constrained by the standard library’s Comparable
protocol (another example of how a core language feature is implemented using a standard Swift protocol), which’ll contain two methods — one for matching against lower values, and one for greater ones:
extension Pattern where Value: Comparable {
static func lessThan(_ value: Value) -> Pattern {
Pattern { $0 < value }
}
static func greaterThan(_ value: Value) -> Pattern {
Pattern { $0 > value }
}
}
The above comes very much in handy anytime we want to compare a value against both a lower and upper bound — like in this example, in which we’re determining whether a user passed a required game score threshold, or whether they achieved a new high score — all within a single switch
statement:
func levelFinished(withScore score: Int) {
switch score {
case .lessThan(50):
showGameOverScreen()
case .greaterThan(highscore):
showNewHighscore(score)
default:
goToNextLevel()
}
}
The cases within a switch
statement are always evaluated top-to-bottom, meaning that the above lessThan
check will be performed before the greaterThan
one.
We’re now starting to uncover the true power of Swift’s pattern matching capabilities, since we’ve gone beyond just matching against single (or groups of) candidates, and are now constructing more complex pattern expressions — all without making our call sites any more complicated.
Converting key paths into patterns
Another way of forming patterns that can be incredibly useful, is by using key paths. Since key paths are already represented by a concrete type, KeyPath
, we simply need to add another ~=
overload in order to enable any key path to be used as a pattern:
func ~=<T>(lhs: KeyPath<T, Bool>, rhs: T?) -> Bool {
rhs?[keyPath: lhs] ?? false
}
Above we’re accepting an optional, T?
, which will enable us to match non-optional key paths against optional values.
With the above in place, we can now freely mix key paths with other kinds of patterns, which will enable us to express even quite complex pieces of logic using just a single switch
statement.
For example, here we’re deciding how to parse a line of text into a list item, based on its first character — by using the Character
type’s category properties to form key path-based patterns, combined with patterns that match against the Optional
enum’s two cases, as well as where
clauses:
struct ListItemParser {
enum Kind {
case numbered
case unordered
}
let kind: Kind
func parseLine(_ line: String) throws -> ListItem {
// Here we're switching on an optional Character, which is
// the type of values that Swift strings are made up of:
switch line.first {
case .none:
throw Error.emptyLine
case \.isNewline:
return .empty
case \.isNumber where kind == .numbered:
return parseLineAsNumberedItem(line)
case "-" where kind == .unordered:
return parseLineAsUnorderedItem(line)
case .some(let character):
throw Error.invalidFirstCharacter(character)
}
}
}
The .none
and .some
cases used above are the two cases that make up Swift’s Optional
enum, which is the type used to model all optional values within a Swift program.
As if the above wasn’t cool enough, let’s also take a look at how we could take any key path-based expression and combine it with a value comparison — enabling us to compose the two in order to form even more powerful patterns.
To make that happen, let’s define yet another operator overload, this time of ==
— which will return a Pattern
that combines a KeyPath
and a constant value, like this:
func ==<T, V: Equatable>(lhs: KeyPath<T, V>, rhs: V) -> Pattern<T> {
return Pattern { $0[keyPath: lhs] == rhs }
}
To take the above for a spin, let’s say that we’re now working on a shopping app, and that we’re calculating the shipping cost for each order based on the Destination
that it’ll be sent to. In this example, our logistics center is located in the city of Paris, which lets us to offer free shipping to everyone living in that city, as well as reduced shipping costs within Europe.
Since we’re now able to combine key paths with values to form patterns, we can simply implement the way we calculate what level of shipping costs that are associated with a given Destination
like this:
struct Destination {
var address: String
var city: String
var country: Country
}
extension Destination {
var shippingCost: ShippingCost {
switch self {
// Combining a key path with a constant value:
case \.city == "Paris":
return .free
// Using a nested key path as a pattern:
case \.country.isInEurope:
return .reduced
default:
return .normal
}
}
}
Pretty cool! Not only does the above read really nicely, it also enables us to easily insert new rules whenever needed, in a way that doesn’t necessarily increase the complexity of our code. We could also keep iterating on our Pattern
struct, and the ways it can be combined with key paths, in order to enable even more powerful combinations to be created.
Conclusion
The fact that Swift’s pattern matching feature isn’t only specifically implemented for a small number of hard-coded types, but is rather a completely dynamic system that can be extended and customized, gives us some incredibly powerful capabilities.
However, like with any powerful system, it’s important to carefully consider when and how to deploy it — and to always use the resulting call sites as guidance as to what kind of patterns that we want to be able to construct. After all, the end goal of using powerful patterns to model complex logic using single switch
statements should be to make that logic easier to understand, not the other way around.
What do you think? To what extent have you used Swift’s pattern matching capabilities so far, and will you try any of the techniques mentioned in this article within your code base? Let me know — and feel free to send me any questions, comments or feedback that you have — either via Twitter or email.
Thanks for reading! 🚀