Custom Types for Powerful Matching

Most modern apps have to deal with URLs in one way or another. Let’s say we have an embedded WKWebView, for example, and we need to decide how to route tapped links. A naive implementation might look something like this:1

func webView(_ webView: WKWebView,
     decidePolicyFor action: WKNavigationAction, 
     decisionHandler: @escaping [...]) {
  guard let url = action.request.url else {
    decisionHandler(.cancel)
  }
  let app = UIApplication.shared

  //If the URL has one of these special 
  //schemes, let the OS deal with it.
  if "tel" == url.scheme
     || "sms" == url.scheme
     || "mailto" == url.scheme {
    app.open(url, options: [:]) {_ in}
    decisionHandler(.cancel)

  //Natively handle these paths that
  //have special meaning.
  } else if url.path == "/logout" {
    performLogout()
    decisionHandler(.cancel)

  } else if url.path == "/about" {
    showAbout()
    decisionHandler(.cancel)

  //Otherwise, just load it.
  } else {
    decisionHandler(.allow)
  }
}

As time passes and our code grows, long chains of if...else statements like these are just asking for trouble. They encourage a very imperative style that can be hard to decipher after the fact. And it’s hard to know where to insert new conditionals or how said conditions will affect existing flow.

An “if” by Any Other Name

A better solution might be to arrange everything in a switch… but things get ugly fast:

switch url {
  case let u where u.scheme == "tel"
       || u.scheme == "sms"
       || u.scheme == "mailto":
    app.open(url, options: [:]) {_ in}
    decisionHandler(.cancel)

  case let u where u.path == "/logout":
    performLogout()
	decisionHandler(.cancel)

  case let u where u.path == "/about":
    showAbout()
	decisionHandler(.cancel)

  default:
    decisionHandler(.allow)
}

The problem here is we have no way to directly match the parts of the URL we’re interested in. We have to bind the whole thing and then use a where clause tease out the components we care about. But where is essentially just an if with a different name. The conditional clauses we give it are the same as the ones we passed to if — complete with all the imperative and readability issues we outlined above.

Declarative Types

What we’d really like is a more declarative way to simply tell switch exactly what we want:

switch url {
  case "mailto:":
    //...
  case "/logout":
    //...
}

The big problem here, though, is most URL components we care about are indistinguishable by type. They’re all Strings in Swift. And not only do they all sort of blend together as we read them, the compiler can’t make heads or tails of it.2

So let’s unique everything by adding a few types of our own:

extension URL {
  struct Scheme {
    let value: String
    init(_ value: String) {
      self.value = value
    }
  }

  struct Path {
    let value: String
    init(_ value: String) {
      self.value = value
    }
  }
}

Now we can write:

switch url {
  case URL.Scheme("tel"),
	   URL.Scheme("sms"),
	   URL.Scheme("mailto"):
	app.open(url, options: [:]) {_ in}
	decisionHandler(.cancel)

  case URL.Path("/logout"):
	performLogout()
	decisionHandler(.cancel)

  case URL.Path("/about"):
	showAbout()
	decisionHandler(.cancel)

  default:
	decisionHandler(.allow)
}

And it looks gorgeous! But, of course, none of this compiles because Swift doesn’t understand how to match a Scheme or a Path to a URL:

Expression pattern of type ‘URL.Scheme’ cannot match values of type ‘URL’

No worries. As we talked about earlier we can teach Swift new expression patterns by overloading the ~= operator:

//Given a control expression that evaluates
//to a URL, and our Scheme type for the pattern, 
//Swift will call this to determine a match: 
func ~=(pattern: URL.Scheme, value: URL) -> Bool {
  //We can then use it to compare our `Scheme` 
  //pattern directly to URL’s `scheme` property.
  return pattern.value == value.scheme
}


//Ditto for a URL and our custom Path type:
func ~=(pattern: URL.Path, value: URL) -> Bool {
  return pattern.value == value.path
}

Composition Considerations

One nice side-effect of this “define a type and matching operator” alternative to endless if...else or case...where clauses is it provides a better path for composability.

Imagine our domain logic has some need to match a secure path; that is, a URL with a given path and the added requirement that the scheme is “https”. switchs are good at handling one pattern or another:

switch url {
case URL.Path("/logout"),
     URL.Path("/signout"):
  //kill user credentials when *either*
  //path is encountered.
}

But they’re not so great when it comes to and:

switch url {
case URL.Scheme("https"):
  switch url {
  case URL.Path("/about"):
    //we have to nest our statements to
    //compose a scheme *and* a path.
  }
}

Thankfully we can just define a new type that represents our domain requirements:

extension URL {
  struct SecurePath {
	let value: String
	init(_ value: String) {
	  self.value = value
	}
  }
}

And then compose our ~= overload from existing matchers:

func ~=(pattern: URL.SecurePath, value: URL) -> Bool {
  return URL.Scheme("https") ~= value 
         && URL.Path(pattern.value) ~= value
}

It’s hard to argue with the readability of the results:

switch url {
  case URL.Scheme("tel"),
	   URL.Scheme("sms"),
	   URL.Scheme("mailto"):
	app.open(url, options: [:]) {_ in}
	decisionHandler(.cancel)

  case URL.Path("/logout"),
       URL.Path("/signout"):
	performLogout()
	decisionHandler(.cancel)

  case URL.SecurePath("/about"):
	showAbout()
	decisionHandler(.cancel)

  default:
	decisionHandler(.allow)
}

1: If you’re not familiar with WKWebView’s navigation delegate, don’t fret. The important thing to know is the action contains a URL, and based on that we decide whether we want the web view to load it (decisionHandler(.allow)) or not load so we can do something else instead (decisionHandler(.cancel)).↩︎

2: We could do something “clever” and regex the string looking for a trailing : to denote a scheme, a leading / for a path, or an internal . for a host, etc. Edge cases abound, however. And at that point, we’re essentially reimplementing our own type system on top of String. Swift has a perfectly good type system already, so I’ll leave the implementation (and attendant disillusionment) as an exercise for the reader.↩︎