Early returning functions in Swift

Returning objects and values from functions is one of those core programming concepts that can be found in most languages. Whether we're talking about using pure functional programming languages like Haskell, or multi-paradigm languages like Swift, functions - and their ability to generate output - are crucial building blocks of any app or system.

However, just like most programming concepts, there's a lot of different ways that functions can be used. Functional programming languages are often centered around the idea of pure functions - functions that don't generate any side effects and always produce the same output given the same input - while when building apps in Swift it's much more common to use a more object-oriented approach, with mutable properties and functions that affect them.

Just like in articles like "First class functions in Swift", this week, let's take a look at how we can be inspired by the functional programming world to improve the structure and robustness of our Swift code - this time focusing on using functions that return objects and values.

Return early, return often

Let's start by taking a look at an example of some code that we wish to improve the structure of. In this case we're working with a NotificationCenter based API in a notes app, and we're responding to a notification telling us that a given note has been updated. To load the updated note, we first must extract its ID from the passed Notification, which we currently do using nested if let statements - like this:

class NoteListViewController: UIViewController {
    @objc func handleChangeNotification(_ notification: Notification) {
        let noteInfo = notification.userInfo?["note"] as? [String : Any]

        if let id = noteInfo?["id"] as? Int {
            if let note = database.loadNote(withID: id) {
                notes[id] = note
                tableView.reloadData()
            }
        }
    }
}

The above code works, but is a bit tricky to read and understand, since it includes multiple levels of indentation and type casting. Let's see if we can improve it!

The first thing we'll do is to apply the concept of early returns, by making our function always return as soon as it possibly can. Instead of using nested if let statements to unwrap our optionals and perform type casting, we'll use the guard statement and simply return in case the required data wasn't found:

class NoteListViewController: UIViewController {
    @objc func handleChangeNotification(_ notification: Notification) {
        let noteInfo = notification.userInfo?["note"] as? [String : Any]

        guard let id = noteInfo?["id"] as? Int else {
            return
        }

        guard let note = database.loadNote(withID: id) else {
            return
        }

        notes[id] = note
        tableView.reloadData()
    }
}

The benefit of returning early, like we now do above, is that it makes the failing conditions of our function a lot more clear. Not only does that improve readability - especially since our code is now much less indented - it's also really helpful when deciding what code paths to write unit tests for. Since each failing condition is now represented by a guard, we can simply add tests matching each guard statement's condition (as well as one for the success path) - and our code should be fully covered.

We can improve things even further by moving out the code extracting the note's ID from the passed notification, into a private extension on the Notification type itself. That way our notification handling code can just contain the actual logic of using a note's ID to perform an update, resulting in an even more clear implementation - like this:

private extension Notification {
    var noteID: Int? {
        let info = userInfo?["note"] as? [String : Any]
        return info?["id"] as? Int
    }
}

class NoteListViewController: UIViewController {
    @objc func handleChangeNotification(_ notification: Notification) {
        guard let id = notification.noteID else {
            return
        }

        guard let note = database.loadNote(withID: id) else {
            return
        }

        notes[id] = note
        tableView.reloadData()
    }
}

Structuring code using early returns and guard statements can also make debugging failures a lot simpler. Rather than always having to step through all of our logic, we can now simply put breakpoints on each guard statement's return, and we'll instantly stop at the condition that's causing the failure.

Conditional construction

When constructing new instances of objects, it's very common that which type of object we need depends on a series of conditions. For example, let's say that which view controller we'll show the user when the app is launched depends on two conditions:

  • Is the user logged in?
  • Has the user gone through our onboarding flow?

Our initial implementation of modelling those conditions might be using a series of if and else statements, like this:

func showInitialViewController() {
    if loginManager.isUserLoggedIn {
        if tutorialManager.isOnboardingCompleted {
            navigationController.viewControllers = [HomeViewController()]
        } else {
            navigationController.viewControllers = [OnboardingViewController()]
        }
    } else {
        navigationController.viewControllers = [LoginViewController()]
    }
}

Again this is a situation where early returns and the guard statement can let us write easier to read and easier to debug code, but in this case we're not talking about failure conditions - but rather just differences in state, so here our early returns won't be about simply exiting out of our function.

Instead, let's use a sort of "lightweight version" of the factory pattern, and move the construction of our initial view controller into a dedicated function. That way we can return a new instance of the view controller that matches the current state of our conditions, like this:

func makeInitialViewController() -> UIViewController {
    guard loginManager.isUserLoggedIn else {
        return LoginViewController()
    }

    guard tutorialManager.isOnboardingCompleted else {
        return OnboardingViewController()
    }

    return HomeViewController()
}

The beauty of the above approach is that it lets us heavily clean up the call site. We no longer have to duplicate the same assignment code in multiple if and else blocks, and can instead simply use the result of calling our above factory function:

func showInitialViewController() {
    let viewController = makeInitialViewController()
    navigationController.viewControllers = [viewController]
}

Since our new makeInitialViewController function is pure (it doesn't mutate any state or generate any side effects), we've essentially turned a piece of branching logic - in which each branch mutates our app's state - into a pure function that's used to perform a single mutation, and fewer mutations most often leads to more predictable code 👍.

Codified conditions

Finally, let's take a look at how functions can help make complex conditions a lot easier to understand. Here we're building a view controller that lets the user display a comment in a social networking app, and if three separate conditions are met then the user is also allowed to edit that comment. Our logic currently takes place in viewDidLoad when we're deciding whether or not to add an edit button to the UI, like this:

class CommentViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        if comment.authorID == user.id {
            if comment.replies.isEmpty {
                if !comment.edited {
                    let editButton = UIButton()
                    ...
                    view.addSubview(editButton)
                }
            }
        }

        ...
    }
}

Just like how we in "Writing self-documenting Swift code" made our code more self-documenting by splitting up large functions (like viewDidLoad tends to become over time) into smaller ones with more clear naming and scope - we can do the same thing to more clearly codify our above conditions.

Since our three nested conditions currently occur in a method used to set up our view controller's subviews, it can be a bit hard to understand why they're there. And unless we have some clear documentation (and tests) explaining why those conditions are related to whether the user should be able to edit a comment, it's easy for someone (including ourselves) to accidentally change or remove one of those conditions in the future.

Instead, let's move those three conditions into a dedicated - clearly named - function, this time implemented using an extension on Comment:

extension Comment {
    func canBeEdited(by user: User) -> Bool {
        guard authorID == user.id else {
            return false
        }

        guard comment.replies.isEmpty else {
            return false
        }

        return !edited
    }
}

Note: If our Comment model had a matching model controller, then that would've been an even better place to put the above logic.

With the above change in place, our view controller can now simply focus on setting up its UI in viewDidLoad, and it becomes very clear what is causing an edit button to be added:

class CommentViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        if comment.canBeEdited(by: user) {
            let editButton = UIButton()
            ...
            view.addSubview(editButton)
        }

        ...
    }
}

Combine the above approach with some simple unit tests covering our new function, and we have heavily reduced the risk of our code being misunderstood (and broken) in the future 🎉.

Conclusion

Using functions, guard statements and early returns are hardly original concepts - but it's easy to forget just how powerful they can be when applied to branching logic, complex conditions or code that deals with multiple optionals. And if we can also structure our code more as pure functions with clear conditions, we'll often end up with code that is much easier to test.

If you want to see a lot of examples of early returns and pure functions, have a look at my recently open sourced Splash framework, which is also what's powering all of the code samples in this article.

What do you think? Do you usually separate your logic into multiple pure functions, or is it something you'll try out? Let me know - along with your questions, comments or feedback - on Twitter, Mastodon or Micro.blog.

Thanks for reading! 🚀

Lightweight presenters in Swift

Enum iterations in Swift 4.2