URL Pattern Matching
When implementing deeplinks for an iOS app recently, I wondered if Swift’s pattern matching could make a viable alternative to a URL routing library. This is an account of the different approaches I tried, and the one I finally settled on.
The majority of URL routing libraries use a pattern syntax that can match each element of a URL’s path in one of three ways:
- Equality: Path element must match the pattern expression
- Value-binding: Path element can be anything at all, and will be made available in a parameters dictionary
- Wildcard: Path element can be anything at all, and will be discarded
For example, it might look something like this:
1 | Router.register("user/:userId/*") { parameters in |
This would match a URL such as scheme://host/user/john_morgan/profilev2
, invoking the closure with a userId
of ‘john_morgan’. There are a few reasons I don’t much like this approach:
- The pattern matching is abstracted away using special syntax.
- The parameter name
userId
is repeated and stringly typed, so it’s susceptible to typos. parameters["userId"]
should never be nil, but the compiler doesn’t know that, so we must force unwrap or add aguard
statement.
As it happens Swift’s built-in pattern matching can be used for each of the three pattern types. Here’s an example of all three:
1 | let example = ("expression-pattern", "value-binding-pattern", "wildcard-pattern") |
In fact, the expression pattern is more powerful than a simple equality test, as we can define our own matching logic using the pattern matching operator (more of which later). The wildcard pattern is really a special case of the value-binding pattern, so I will refer to them collectively as value-binding patterns from here on in.
Swift’s pattern-matching would seem a natural fit for matching URLs, and Swift’s switch statement would suit the purpose too, so I decided to investigate a URL routing approach based on the two.
NSURL exposes a pathComponents
as an Array<String>
, e.g., https://myhost.com/user/14253/profilev2
would give ["/", "user", "14253", "profilev2"]
. Let’s assume we remove the initial backslash and call the resulting array pathElements
. In pseudo-Swift, I’d like to be able to switch on the array a bit like this:
1 | switch pathElements { |
However, there is no built-in pattern matching for Arrays and their elements in Swift, so we need to add it somehow…
Approach 1: Pattern Matching Operator
My first thought was to use the pattern-matching operator (~=
) to match Arrays of equatable elements based on all elements being equal:
1 | func ~=<T: Equatable>(pattern: [T], value: [T]) -> Bool { |
This would allow us to match simple patterns in a switch statement:
1 | switch pathElements { |
However, the pattern-matching operator can only be used for expression patterns. It cannot be used for adding custom value-binding patterns, so this is a dead end. We need to convert the array into another type that already supports value-binding patterns for its elements.
Approach 2: Tuples
This led me to think about tuples, as tuples support value-binding patterns for their elements. To convert the pathElements
array into a tuple with the same number of elements, perhaps a decompose()
method could be overloaded for element counts up to some sensible limit:
1 | extension Array { |
This would enable pattern-matching like so:
1 | if case ("user", let userId, _)?: (String, String, String)? = pathElements.decompose() { |
Unfortunately the compiler can’t infer which decompose()
method to invoke, which necessitates the explicit typing after the colon above. Abandoning the overloaded decompose()
in favour of unique method names decompose1()
, decompose2()
, decompose3()
etc. helps to clean things up:
1 | if case ("user", let userId, _)? = pathElements.decompose3() { |
However, this serves to highlight a limitation: we can’t use this approach to match multiple patterns within a single switch statement, unless those patterns happen to have the same element count. In the example above, what would we switch on - decompose2()
or decompose3()
? Instead, we need a structure that can represent different element counts within the same type…
Approach 3: Linked List
This led me to try using an enum type, as enums also support value-binding patterns for their associated values. A linked list (here’s a nice implementation by Airspeed Velocity) seemed promising because it’s built out of enums and can represent an arbitrary number of elements. Here’s what it would look like:
1 | if case .Node("user", .Node(let userId, .Node(_, .End))) = List(pathElements) { |
Unlike the previous approach, it can also be used to pattern-match lists of any size within a single switch statement:
1 | switch List(pathElements) { |
The trouble is, well, it’s ugly. All those parentheses and repeated .Node
s make it very difficult to read. .Node
could be shortened to a single character but nesting multiple enums still generates a confusing amount of parentheses.
Approach 4: Counted
My final approach was a compromise between approaches 2 and 3. What was needed was an enum that could represent arbitrary numbers of elements without needing too many layers of nesting. Enter Counted
:
1 | enum Counted<E> { |
Counted is an enum where each case has a different number of elements as associated values. It can be initialized with an Array, and just like List
, there’s an indirect case that enables arbitrarily large arrays to be represented via nested Counted
enums. Unlike List
, a layer of nesting is only required for every 10 elements, which makes things easier to read. Counted
enables us to pattern-match paths with any number of elements, and supports expression and value-binding patterns for its associated values. It can also be used in switch statements:
1 | switch Counted(pathElements) { |
This can be extended for even greater flexibility. I mentioned that the expression pattern can be used to match based on more than simple equality. For example, I created a Regex
struct that can match Strings based on a regular expression, and implemented the pattern-matching operator like so:
1 | public func ~=(regex: Regex, string: String) -> Bool { |
As a result we can use Regex
to match individual path elements within Counted
. For example, the following case would match both /pages/contact-us_gbr
and /pages/contact-us_usa
:
1 | case .N2("pages", Regex("contact_.*")): |
I added structs Begins(...)
and Ends(...)
, which use the pattern-matching operator to match Counted
instances based purely on a slice of the path elements. I also added extensions to NSURL
and NSURLComponents
to make a Counted
list of path elements and a Dictionary
of query arguments easily available. The code is available here: URLPatterns.
Deep-linking
Now that I can do more idiomatic Swift pattern-matching for URL path elements, here’s how I use it for deep-linking. I define my app’s deep-link destinations as an enum:
1 | enum DeepLink { |
I then add a failable initializer to DeepLink
, which takes an NSURL
. This is where the pattern-matching happens:
1 | extension DeepLink { |
Once the URL has been converted into a DeepLink
, it can be passed to a DeepLinker
for routing:
1 | struct DeepLinker { |
With that set up, opening a deeplink looks like this:
1 | if let link = DeepLink(url: url) { |
I prefer this approach to the approach taken by most URL routing libraries for a few reasons:
It’s simple to bypass URLs and open a deeplink directly, e.g. by calling
DeepLinker.open(.Home)
.The pattern-matching code is no longer in a third-party library, which makes it easier to debug.
The pattern-matching code leverages Swift’s built-in pattern-matching, which means it can be customized and extended.
The pattern-matching and routing processes are separated into two steps. This provides an override point if needed, e.g.:
1 | if let link = DeepLink(url: url) where !link.requiresLogin { |
What do you think? Do you like the ‘swiftier’ approach (damn, I nearly managed to avoid that word), or am I misrepresenting URL routing libraries?