Articles, podcasts and news about Swift development, by John Sundell.

Defining custom patterns in Swift

Published on 17 Nov 2019

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! 🚀