[Pitch] Guarded Failpoints - Adding Enhanced Logic To Guard Statements

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.

Honestly, this just seems like a job for a type you initialize with your conditions and then check computed values on. If you use an enum you get the exact syntax you mention.

13 Likes

Seems like what you want is a function that throws for any of the failed conditions, right?

1 Like

I agree this would be a way of making this work without adding any additional functionality, and it was considered when I came up this idea, but honestly, it felt like a long-winded way to achieve the motivation.

Creating a new 'failpoint' can take the work needed to set up an enum to track which condition failed, and condense it into an easy readable and clean in-line approach.

As with the comment from Jon, I agree, this is a potential approach to solve this, but using a pattern that involves creating additional functions to throw based on which condition failed could require a lot of unneeded code, and even make guard statements effectively redundant on a certain level. Guard statements provide a clear, readable way to bail out functions when needed, and I've tried to stick to that with this idea. Failpoints can take that unneeded code, and provide it all in-line.

To me, the only thing that is shared across the “bloated” version with multiple guard statements is the call to “completion”. Given that async/await should handle those cases more elegantly, I don’t think it’s worth adding syntax to Swift just to handle this case. Even without it you could probably use defer to catch this case safely anyway.

Without that repetition the version with multiple guards looks fine to me tbh. Seems clearer to me than the version with “failpoint” and possibly less code too.

Even though I do recognise the frustration you have with not knowing which statement in the guard “failed”, I don’t think it warrants a fundamental change to the language to achieve this. Swift is already a very complex language - it is worth keeping a very high bar for adding more to it

1 Like

I often find myself wishing I could combine guard statements like this, but I don’t think it's actually a good idea. As others have pointed out, it’d make more sense to use error-handling.

The reason everyone is reluctant to use errors like that, in my opinion, is the lack of typed throws. Once you have a throwing function right now, you have to accomodate any Error even if that’s impossible.

If it wasn’t for that, errors would be the obvious way to convey specific reasons for failure. But since using errors means dealing with impossible program states, here we are.

Terms of Service

Privacy Policy

Cookie Policy