Introduction:
Guard statements are an essential part of the Swift code language, but has one major flow: you cannot identify which condition in a guard statement fails. This can result in multiple guard statements being required to implement desired behaviour, increasing the potential for bugs from missing completion hander calls, and messy, bloated code.
This pitch is to solve this issue with “Guarded Failpoints”.
A failpoint is a collection of conditions in a guard statement. If any of the conditions in a failpoint fails, the failpoint will be flagged as failing. Then, in the else of the guard statement, the flagged failpoint is provided, allowing us the run a switch on exactly why the guard statement failed.
Motivation
Consider an example from a mocked shopping app, implementing logic for purchasing an order. This logic includes a completion handler.
For this function, we need to ensure the User? object isn’t nil, the Cart? object isn’t nil, the user is signed in, and the cart isn’t empty. We could look to implement this with a simple guard statement.
func purchaseItems(completion: () -> ()) {
guard let user = user,
let cart = cart,
let user.isSignedIn,
let !cart.items.isEmpty else {
completion()
return
}
//Purchase logic
}
However, we need to implement different behaviour based on which condition fails.
- If the user or the cart are nil, something has gone very wrong in the app, so we want to return to the launch screen.
- If the user isn’t signed in, then we went to show a pop up with text saying “Please sign in!”
- If the cart is empty, we want to display a pop up with text saying “Your cart is empty!”
This could result in needing multiple guard statements.
func purchaseItems(completion: () -> ()) {
guard let user = user,
let cart = cart else {
launchStartingScreen()
completion()
return
}
guard user.isSignedIn else {
displayPopUp(text: "Please sign in!")
completion()
return
}
guard !cart.items.isEmpty else {
displayPopUp(text: "Your cart is empty!")
completion()
return
}
//Purchase logic
}
Even for the basic example, this is bloated code. Additionally, there is a chance we forget to call the completion handler in one of the guard’s else blocks, leading to unexpected behaviour that may be hard to debug.
Proposed Solution
I am proposing “guarded failpoints” to resolve this problem. As discussed, a failpoint is a collection of conditions. If one condition in a failpoint fails, the associated failpoint will be flagged as failing.
With guarded failpoints, the guard statements in the example then becomes:
func purchaseItems(completion: () -> ()) {
guard failpoint missingDependency = let user = user,
let cart = cart,
failpoint userConfiguration = user.isSignedIn,
failpoint cartConfiguration = !cart.items.isEmpty else {
...
}
//Purchase logic
}
The first failpoint, “missingDependency”, is made up of the conditions to ensure the user object isn’t nil, and ensuring the cart object isn’t nil. If either condition fails, then “missingDependency” failpoint fails.
The second failpoint, “userConfiguration”, is made up of the condition that the user is signed in.
The third failpoint, “cartConfiguration”, is made up of the condition that the cart isn’t empty.
If a condition fails, and we move into the else block, we will have access to the failing failpoint - simply called failpoint. We can then run different behaviours depending on the failpoint responsible.
guard failpoint missingDependency = let user = user,
let cart = cart,
failpoint userConfiguration = user.isSignedIn,
failpoint cartConfiguration = !cart.items.isEmpty else {
switch failpoint {
case .missingDependency: launchStartingScreen()
case .userConfiguration: displayPopUp(text: "Please sign in!")
case .cartConfiguration: displayPopUp(text: "Your cart is empty!")
}
completion()
return
}
This will remove the risk of missing calls to the completion handler and keeps code clean, simple and readable.