BLACK FRIDAY: Save 50% on all books and bundles! >>

Understanding Swift’s Result type

Paul Hudson    @twostraws   

It is common to want a function to return some data if it was successful, or return an error if it was unsuccessful. We usually model this using throwing functions, because if the function call succeeds we get data back, but if an error is thrown then our catch block is run, so we can handle both independently. But what if the function call doesn’t return immediately?

We looked at networking code previously, using URLSession. Let’s look at another example now, adding to the default SwiftUI template code:

Text("Hello, World!")
    .onAppear {
        let url = URL(string: "https://www.apple.com")!
        URLSession.shared.dataTask(with: url) { data, response, error in
            if data != nil {
                print("We got data!")
            } else if let error = error {
                print(error.localizedDescription)
            }
        }.resume()
    }

As soon as the text view loads the network request will start, fetch some data from apple.com, and print one of two messages depending on whether the network request worked or not.

If you recall, I said the completion closure will either have data or error set to a value – it can’t be both, and it can’t be neither, because both those situations don’t make sense. However, because URLSession doesn’t enforce this constraint for us we need to write code to handle the impossible cases, just to make sure all bases are covered.

Swift has a solution for this confusion, and it’s a special data type called Result. This gives us the either/or behavior we want, while also working great with non-blocking functions – functions that perform their work asynchronously so they don’t block the main code from running. As a bonus, it also lets us return specific types of errors, which makes it easier to know what went wrong.

The syntax is a little odd at first, which is why I’m warming you up slowly – this stuff is really useful, but if you jump in at the deep end it can feel like a step backward.

What we’re going to do is create a wrapper for our above networking code so that it uses Swift’s Result type, meaning that you can clearly see the before and after.

First we need to define what errors can be thrown. You can define as many as you want, but here we’ll say that the URL is bad, the request failed, or an unknown error occurred. Put this enum outside the ContentView struct:

enum NetworkError: Error {
    case badURL, requestFailed, unknown
}

Next, we’re going to write a method that sends back a Result. Remember, Result is designed to represent some sort of success or failure, and in this instance we’re going to say that the success case will contain the string of whatever came back from the network, and the error will be some sort of NetworkError.

We’re going to write this same method four times in increasing complexity, so you can see how things build up. To start with, we’re just going to send back a badURL error immediately, which means adding this method to ContentView:

func fetchData(from urlString: String) -> Result<String, NetworkError> {
    .failure(.badURL)
}

As you can see, that method’s return type is Result<String, NetworkError>, which is what says it will either be a string on success or a NetworkError value on failure. This is still a blocking function call, albeit a very fast one.

What we really want is a non-blocking call, which means we can’t send back our Result as a return value. Instead, we need to make our method accept two parameters: one for the URL to fetch, and one that is a completion closure that will be called with a value. This means the method itself returns nothing; its data is passed back using the completion closure, which is called at some point in the future.

Again, we’re just going to make this return a .badURL failure to keep things simple. Here’s how it looks:

func fetchData(from urlString: String, completion: (Result<String, NetworkError>) -> Void) {
    completion(.failure(.badURL))
}

Now, the reason we have a completion closure is that we can now make this method non-blocking: we can kick off some asynchronous work, make the method return so the rest of the code can continue, then call the completion closure at any point later on.

There is one small complexity here, and although I’ve mentioned it briefly before now it becomes important. When we pass a closure into a function, Swift needs to know whether it will be used immediately or whether it might be used later on. If it’s used immediately – the default – then Swift is happy to just run the closure. But if it’s used later on, then it’s possible whatever created the closure has been destroyed and no longer exists in memory, in which case the closure would also be destroyed and can no longer be run.

To fix this, Swift lets us mark closure parameters as @escaping, which means “this closure might be used outside of the current run of this method, so please keep its memory alive until we’re done.”

In the case of our method, we’re going to run some asynchronous work then call the closure when we’re done. That might happen immediately or it might take a few minutes; we don’t really care. The point is that the closure still needs to be around after the method has returned, which means we need to mark it @escaping. If you’re worried about forgetting this, don’t be: Swift will always refuse to build your code unless you add the @escaping attribute.

Here’s the third version of our function, which uses @escaping for the closure so we can call it asynchronously:

func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
    DispatchQueue.main.async {
        completion(.failure(.badURL))
    }
}

Remember, that completion closure can be called at any point in the future and it will still work just as well.

And now for our fourth version of the method we’re going to blend our Result code with the URLSession code from earlier. This will have the exact same function signature – accepts a string and a closure, and returns nothing – but now we’re going to call the completion closure in different ways:

  1. If the URL is bad we’ll call completion(.failure(.badURL)).
  2. If we get valid data back from our request we’ll convert it to a string then call completion(.success(stringData)).
  3. If we get an error back from our request we’ll call completion(.failure(.requestFailed)).
  4. If we somehow don’t get data or an error back then we’ll call completion(.failure(.unknown)).

The only new thing there is how to convert a Data instance to a string. If you recall, you can go the other way using let data = Data(someString.utf8), and when converting from Data to String the code is somewhat similar:

let stringData = String(decoding: data, as: UTF8.self)

OK, it’s time for the fourth pass of our method:

func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
    // check the URL is OK, otherwise return with a failure
    guard let url = URL(string: urlString) else {
        completion(.failure(.badURL))
        return
    }

    URLSession.shared.dataTask(with: url) { data, response, error in
        // the task has completed – push our work back to the main thread
        DispatchQueue.main.async {
            if let data = data {
                // success: convert the data to a string and send it back
                let stringData = String(decoding: data, as: UTF8.self)
                completion(.success(stringData))
            } else if error != nil {
                // any sort of network failure
                completion(.failure(.requestFailed))
            } else {
                // this ought not to be possible, yet here we are
                completion(.failure(.unknown))
            }
        }
    }.resume()
}

I know it took quite a bit of work, but I wanted to explain it step by step because there’s a lot to take in. What it gives us is a much cleaner API because we can now always be sure that we either get a string or an error – it’s impossible to get both of them or neither of them, because that’s not how Result works. Even better, if we do get an error then it must be one of the cases specified in NetworkError, which makes error handling much easier.

All we’ve done so far is to write functions that use Result; we haven’t written anything that handles the Result that got sent back. Remember, regardless of what happens the Result always carries two pieces of information: the type of result (success or failure), and something inside there. For us, that’s either a string or a NetworkError.

Behind the scenes, Result is actually an enum with an associated value, and Swift has very particular syntax for dealing with these: we can switch on the Result, and write cases such as case .success(let str) to mean “if this was successful, pull out the string inside into a new constant called str.

It’s easier to see this all in action, so let’s attach our new method to our onAppear closure, and handle all possible cases:

Text("Hello, World!")
    .onAppear {
        self.fetchData(from: "https://www.apple.com") { result in
            switch result {
            case .success(let str):
                print(str)
            case .failure(let error):
                switch error {
                case .badURL:
                    print("Bad URL")
                case .requestFailed:
                    print("Network problems")
                case .unknown:
                    print("Unknown error")
                }
            }
        }
    }

Hopefully now you can see the benefit: not only have we eliminated the uncertainty of checking what was sent back, but we also eliminated optionality entirely. There isn’t even a need for a default case for the error handling, because all possible cases of NetworkError are specifically covered.

Save 50% in my Black Friday sale.

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.6/5

Link copied to your pasteboard.