Swift Regex Deep Dive
iOS MacOur introductory guide to Swift Regex. Learn regular expressions in Swift including RegexBuilder examples and strongly-typed captures.
Swift style encourages developers to use the compiler to their advantage, and one of the ways to accomplish this is to leverage the type system. In many cases, doing so can feel fairly obvious, but working with UIKit
can be challenging since it often hands you String
instances to identify view controllers, storyboards and so on. We have received some guidance on this issue in the form of a WWDC session in 2015, but it’s a good idea to revisit the problem to continue our practice of thinking Swiftly.
Let’s take a look at storyboard segues as our example, which can be especially tricky. One of the difficulties arises from the fact that UIKit
requires that we use a String
to identify the segue that we want to use. That means, in a sense, that UIStoryboardSegue
s are “stringly” typed. This makes it difficult to work with segues in a type-safe manner, and can lead to a lot of repetitive code. How can we address this problem?
You may have seen something like this out in the wild. You may have even written this code before (gasp!).
// Sitting somewhere in the source for a UIViewController
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else {
assertionFailure("Segue had no identifier")
return
}
if identifier == "showPerson" {
let person = Person(name: "Matt")
let personVC = segue.destination as! PersonViewController
personVC.person = person
} else if identifier == "showProduct" {
let product = Product(title: "Book")
let productVC = segue.destination as! ProductViewController
productVC.product = product
} else {
assertionFailure("Did not recognize storyboard identifier")
}
}
Here, we have a prepare(for:sender:)
method overridden in a UIViewController
.
It grabs the identifier
from the inbound segue
, and then matches against a couple of hardcoded strings.
These strings, like "showPerson"
and "showProduct"
, will match a segue seeking to show a PersonViewController
or a ProductViewController
.
This approach is overly mechanical, which can lead to buggy code.
It is easy to mistype the segue identifiers.
It is also easy to forget to add a new segue identifier to this list.
Forgetting to capture an identifier in one of the else
clauses above would make for a likely crash in the next view controller.
Notice that we use assertionFailure()
above and not preconditionFailure()
.
assertionFailure()
will be caught in debug mode, whereas preconditionFailure()
will crash in both debug and release build configurations.
This is perfect for testing your application while you are developing it.
Ideally, we aim to capture all of these sorts of bugs during our development cycle.
In the unfortunate circumstance that we do not, it is worth it to let our segue proceed as usual.
It’s possible that things will go okay and that the subsequent view controller will have the data it needs—of course, this depends upon the data and circumstances at hand, but it is good to let the app proceed if it makes sense.
Preemptively crashing with preconditionFailure()
is not always the best option.
There has to be a better way.
What we are looking for is something that will help us to catch these errors before we ever get to the runtime.
How can we catch this sort of error before we get there?
Let’s take a moment to examine the if/else
statement we have above.
Notice that we have several clauses; this is no simple if/else
.
A general rule of thumb to recall is that if an if/else
statement has multiple clauses, then it may be suitable to replace it with a switch
statement.
Let’s see what that looks like.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier! else {
assertionFailure("Segue had no identifier")
return
}
switch identifier {
case "showPerson":
let person = Person(name: "Matt")
let personVC = segue.destination as! PersonViewController
personVC.person = person
case "showProduct":
let product = Product(title: "Book")
let productVC = segue.destination as! ProductViewController
productVC.product = product
default:
assertionFailure("Did not recognize storyboard identifier")
}
}
Refactoring to a switch statement helps us to see the path forward.
Switching over a String
like identifier
feels a little buggy.
Instead of switching over a String
, we’d rather switch over something whose set of possible values is more determined.
Enumerations are perfect in this scenario.
Let’s think about what is needed.
Obviously, we need to replace to replace the identifier
String
with an enumeration case.
We can do this with an enumeration whose raw values are String
s.
This will allow us to define a type whose cases comprise the segues we anticipate to be passed to prepare(for:sender:)
.
It will also yield an enumeration whose cases are backed by String
instances.
String
raw values will be nice when we need to match against the incoming segue’s identifier
.
Here’s an enumeration that defines cases for the segues we have seen so far.
extension ViewController {
enum ViewControllerSegue: String {
case showPerson
case showProduct
}
}
I like to define this enumeration as a nested type within its associated UIViewController
.
It helps to make the relationship clear: The view controller is what helps to prepare for a segue before it is performed.
In the example at hand, ViewControllerSegue
is simply there to help out by providing comprehensive information to the compiler.
How does this work?
Well, we need to modify our override
of prepare(for:sender:)
above.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier,
let identifierCase = ViewController.ViewControllerSegue(rawValue: identifier) else {
assertionFailure("Could not map segue identifier -- (segue.identifier) -- to segue case")
return
}
switch identifierCase {
case .showPerson:
let person = Person(name: "Matt")
let personVC = segue.destination as! PersonViewController
personVC.person = person
case .showProduct:
let product = Product(title: "Book")
let productVC = segue.destination as! ProductViewController
productVC.product = product
}
}
This implementation of prepare(for:sender:)
leverages our new enumeration.
Its first task is to get the identifier
associated with the segue
and transform it into an instance of ViewControllerSegue
.
If this fails, then you assertionFailure()
with the relevant debug information.
Otherwise, you have the information you need to safely switch over the segue.identifier
.
Finally, the switch we use exhaustively checks all of the possible cases.
This helps in a couple of ways.
First, the switch allows us to avoid repeating the calls to assertionFailure()
.
We are able to avoid this redundancy because the switch can determine whether or not we are covering all of the enumeration’s cases.
Second, this switch can also warn us if we forget to cover a new segue.
The best path here is that we remember to add a new case to our ViewControllerSegue
enumeration.
Doing so will trigger the compiler to issue an error in the above switch if it does not cover the new case.
If we forget to add the new segue identifier case to our enumeration, then we will hit the assertionFailure()
within our guard
statement during our testing.
Either way, the above code is less repetitive and gives more information to the compiler to help us avoid bugs at runtime.
While we are on the topic of making our code less repetitive, let’s rethink our approach.
Currently, every view controller that wants to take advantage of the segue enumeration will need to remember to do two things.
First, it will need to provide an enumeration with cases for all of the segues that it needs to handle.
Second, it will need to write the guard
statement above in prepare(for:sender)
to map the segue.identifier
to a segue case in the enumeration.
That will get a little tedious to remember to type every time we make a new UIViewController
subclass.
Protocol extensions can help to alleviate this issue.
protocol SegueHandler {
associatedtype ViewControllerSegue: RawRepresentable
func segueIdentifierCase(for segue: UIStoryboardSegue) -> ViewControllerSegue?
}
The protocol above uses an associatedtype
named ViewControllerSegue
. Conforming types will have to provide a nested type of the same name enumerating all of the segues the view controller expects to handle. These nested types will have to be RawRepresentable
, which will work well with our String
backed segue cases. Last, the protocol requires a method that will take a UIStoryboardSegue
and map to an of the nested enumeration. This method returns an optional to handle the scenario of not being able to map the segue.identifier
to a specifc case on the nested enumeration.
We can use a protocol extension to provide a default implementation of the required method above.
extension SegueHandler where Self: UIViewController, ViewControllerSegue.RawValue == String {
func segueIdentifierCase(for segue: UIStoryboardSegue) -> ViewControllerSegue {
guard let identifier = segue.identifier,
let identifierCase = ViewControllerSegue(rawValue: identifier) else {
return nil
}
return identifierCase
}
}
Now all you have to do on your UIViewController
subclasses is to declare conformance to the SegueHandler
protocol.
Doing so will nudge the compiler to issue you an error if your view controller does not provide the nested type ViewControllerSegue
.
(Remember that you listed this requirement in the protocol via an associatedtype
.
Now, heading back to ViewController
, our prepare(for:segue)
method looks like so:
// extension ViewController: SegueHandler {} somewhere in the file...
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifierCase = segueIdentifierCase(for: segue) else {
assertionFailure("Could not map segue identifier -- (segue.identifier) -- to segue case")
return
}
switch identifierCase {
case .showPerson:
let person = Person(name: "Matt")
let personVC = segue.destination as! PersonViewController
personVC.person = person
case .showProduct:
let product = Product(title: "Book")
let productVC = segue.destination as! ProductViewController
productVC.product = product
}
}
This is a bit nicer, but it can get better.
We’ve written a protocol and protocol extension to handle the mapping from segue.identifier
to segue enumeration case.
This is great because conforming UIViewController
subclasses will get some help from the compiler to ensure that we provide the correct enumeration for our view controller’s segues.
But we still have a guard
statement above, and that’s because we currently have segueIdentifierCase(for:)
returning an optional.
It would be nicer to not have to worry about optionals.
Before we update segueCaseIdentifier(for:)
to not return an optional, let’s talk about segues without identifiers.
Our current approach means that we will trap in the guard
statement above if our segue doesn’t have an identifier.
This really isn’t a problem because all of our segues should have an identifier.
But, you say, I dont need an identifier because I’m not passing any data to the next view controller.
I just need to show it.
Okay, okay.
Unnamed segues have an empty String
identifier: ""
.
Let’s add a new unnamed
case to ViewControllerSegue
.
extension ViewController {
enum ViewControllerSegue: String {
case showPerson
case showProduct
case unnamed = ""
}
}
The compiler is now bugging us that we need to make our switch
statement exhaustive in prepare(for:sender:)
above, so let’s add the new case.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifierCase = segueIdentifierCase(for: segue) else {
assertionFailure("Could not map segue identifier -- (segue.identifier) -- to segue case")
return
}
switch identifierCase {
case .showPerson:
let person = Person(name: "Matt")
let personVC = segue.destination as! PersonViewController
personVC.person = person
case .showProduct:
let product = Product(title: "Book")
let productVC = segue.destination as! ProductViewController
productVC.product = product
case .unnamed:
assertionFailure("Segue identifier empty; all segues should have an identifier.")
}
}
Notice that we use assertionFailure()
again to accomodate for the possibility that our app won’t crash at runtime, but also to give us the nudge during our development that we really ought to provide an identifier to the segue.
Now that we have a new case, we are in a good position to revisit our default implementation of segueCaseIdentifier(for:)
.
extension SegueHandler where Self: UIViewController, ViewControllerSegue.RawValue == String {
func segueIdentifierCase(for segue: UIStoryboardSegue) -> ViewControllerSegue {
guard let identifier = segue.identifier,
let identifierCase = ViewControllerSegue(rawValue: identifier) else {
fatalError("Could not map segue identifier -- (segue.identifier) -- to segue case")
}
return identifierCase
}
}
This new default implementation uses fatalError()
.
Why did we make this change?
The answer has to do with the new ViewControllerSegue.unnamed
case.
This case acts as a kind of friendly default
case for our switch
statement.
All unnamed segues will match against this case.
I call this a “friendly” default
case because it won’t ruin our switch
’s attempts at being exhaustive.
If we add a new case, then the compiler will see that our switch
doesn’t match against it and issue an error.
Therefore, segueIdentifierCase(for:)
should never fail to generate a ViewControllerSegue
.
If it receives an empty string (in the case of a segue without an identifier), then it will create an instance of ViewControllerSegue
set to .unnamed
.
It may receive a segue with an identifier that doesn’t match a case in our enumeration, which would be bad.
After all, we have an .unnamed
case, which means that we purposefully chose to give the segue an identifier.
That suggests we need to pass that view controller some data.
This sounds like an unrecoverable error, and so crashing is the right way to go.
We can finally head back to prepare(for:sender:)
to remove the optional unwrapping and streamline our code.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segueIdentifierCase(for: segue) {
case .showPerson:
let person = Person(name: "Matt")
let personVC = segue.destination as! PersonViewController
personVC.person = person
case .showProduct:
let product = Product(title: "Book")
let productVC = segue.destination as! ProductViewController
productVC.product = product
case .unnamed:
assertionFailure("Segue identifier empty; all segues should have an identifier.")
}
}
Since segueIdentifierCase(for:)
doesn’t return an optional, we can simply switch over its result.
UIKit
’s API often expects to receive and hands back String
instances to interface with storyboards, view controllers and segues.
This can lead to buggy code. For example, it’s easy to mistype the String
used to identify the item you need.
One useful way we can improve our interaction with UIKit
is to leverage Swift’s type system to limit our options.
Enumerations are especially suited for this work as they define a precise listing of options for a type.
Swift’s enumerations work perfectly here because we can back each case’s raw value with a String
that corresponds to a specifc resource.
We can also use protocols and protocol extensions to help. Using these help to remove redundancy in our code. They also leverage the compiler’s knowledge of the protocol’s requirements to remind us to write safer code.
Our introductory guide to Swift Regex. Learn regular expressions in Swift including RegexBuilder examples and strongly-typed captures.
The Combine framework in Swift is a powerful declarative API for the asynchronous processing of values over time. It takes full advantage of Swift...
SwiftUI has changed a great many things about how developers create applications for iOS, and not just in the way we lay out our...