Writing Custom Pattern Matching in Swift

Writing Custom Pattern Matching in Swift

Pattern matching is available everywhere in Swift, and you have likely used it tons of times to deconstruct and bind values in things like switch cases. While regular switch cases are the most common use for patterns, Swift has several types of patterns which can be mixed and even used outside of switches to result in really cool and short lines of code. One thing that interests me in particular is that pattern matching can be used for a wide variety of things.

At first glance, it's easy to see pattern matching as simple equality checks:

switch 80 {
case 100:
    //
case 80:
    //Matches, because 80 == 80
default:
    break
}

Given that, you might think that something like case "eighty" wouldn't compile, after all, "eighty" and 80 aren't even the same type - and it's indeed what happens if you try it right now:

if case "eighty" = 80 {
    //error: expression pattern of type 'String' cannot match values of type 'Int'
}

But that's not necessarily the case. When developing your projects, you might have noticed that some types have special interactions between each other, such as Ranges and their associated types:

switch 80 {
case 0...20:
	break
case 21...50:
    break
case 51...100:
    //Matches, because 80 is inside 51...100
default:
    break
}

The reason for this is the ~= pattern matching operator. This operator doesn't see much use in regular projects (you might have seen it before for this exact is number inside range example), but it is used a lot internally in Swift and it's exactly what's used to confirm case statements.

For most cases, the operator is a simple wrapper for an equality check (like the Int example), but Range has a special implementation for ~= when used against its own associated type, allowing it to have this custom behaviour when pattern matching:

extension RangeExpression {
    @inlinable
    public static func ~= (pattern: Self, value: Bound) -> Bool {
      return pattern.contains(value)
    }
}

And since ~= is available globally, you can overload it in order to write your own pattern matching logic!

To make "eighty" match 80, for example, all you need to do is add a version of the operator that matches String patterns with Int values:

func ~= (pattern: String, value: Int) -> Bool {
    if pattern == "eighty" {
        return value == 80
    } else if pattern == "not eighty" {
        return value != 80
    } else {
        return false
    }
}

switch 80 {
case "eighty":
    //Compiles and matches!
case "not eighty":
    //
default:
   break
}

Now let's say my app recieved a deep link in the shape of a path string and I need to decide which of my tabBar's UIViewControllers this deep link belongs too - in the form of a AppTab type:

enum AppTab: String {
    case home
    case orderHistory
    case profile
}

let deepLink = DeepLink(path: "home", parameters: [:])

There are several ways to do this, including adding a correspondingTab property to the deep link itself or subclassing the DeepLink type, but with custom pattern matching, this attribution can be one-lined without having to touch the DeepLink type!

func ~= (pattern: AppTab, value: DeepLink) -> Bool {
    return value.path.hasPrefix(pattern.rawValue)
}

switch deepLink {
case .home:
    homeViewController.handle(deepLink: deepLink)
case .orderHistory:
    historyViewController.handle(deepLink: deepLink)
case .profile:
    profileViewController.handle(deepLink: deepLink)
default:
    break
}

This allows you to bypass having to map broader types into more specific ones, such as how an Int can be mapped to a WeekDay enum. In this case in particular, your backend would return you the week day either as an Int or a String, and with custom pattern matching you can make use of this broader type while still treating it like it was mapped to the more specific enum type. This can be useful when you want to try concepts without commiting to certain approachs or types:

enum WeekDay: Int {
    case sunday
    case monday
    case tuesday
    case wednesday
    case thursday
    case friday
    case saturday
}

func ~= (pattern: WeekDay, value: Int) -> Bool {
    return pattern.rawValue == value
}

// Server returns:
// { nextHoliday: { weekDay: 5 } }

if case .friday? = nextHoliday?.weekDay {
    print("Woohoo!")
}

Creation of custom patterns is a simple way to write cleaner code without too much effort, as you can leverage cases to jump straight to the point without having to add additional properties to your types - while making sure your code doesn't become harder to understand.

References and Good reads

Apple Docs: Patterns