DEV Community

Bugfender
Bugfender

Posted on • Originally published at bugfender.com on

Effective Swift Error Handling Techniques for iOS Developers

As programmers we know that, despite our best efforts, we’ll never be able to completely eliminate errors from our apps. The sheer complexity of modern apps, not least the reliance on dynamic (often third-party) inputs, means errors are inevitable and error handling (exception handling) is crucial to user experience.

Today we’re going to take a look at how we can effectively handle errors in Swift, including:

  • Declaring errors in Swift
  • Throwing errors in Swift
  • Catching errors in Swift
  • Error propagation in Swift
  • Swift error handling patterns

We’ll then demonstrate these techniques using a simple network client as an example. Ready? Let’s dive in…

Declaring errors in Swift

Declaring errors in Swift is simple and we start by defining the custom error type by conforming it to the Error protocol. Errors can be of any type, however, as the most important aspect is knowing which error occurred and being able to react to it, best practice is to use a Swift enumeration, as shown below:

enum MyErrorDomain: Error {
    case MyErrorType
    case AnotherErrorType
}

Enter fullscreen mode Exit fullscreen mode

Throwing errors in Swift

One of the most important considerations when handling errors is the ability to throw them, which is essentially the way we let our methods know an error has occurred. In Swift we can do this by using the throws keyword to indicate a function can throw an error and the throw statement inside the function to actually throw the error.

As an example we’ve set up a simple method to open a file and write to it, then throw any errors as appropriate:

enum FileError: Error { 
    case fileNotFound 
    case permissionDenied 
} 

func writeToFile(named filename: String) throws { 
    // Read file 
    if fileNotFound { 
        throw FileError.fileNotFound 
    } 

    // Write to file
    if permissionsDenied {
        throw FileError.permissionDenied
    }
}

Enter fullscreen mode Exit fullscreen mode

This is obviously a very simple example but we can see how errors are thrown and how we throw a different error based on the error condition.

Catching errors in Swift

Now we know how to throw errors, let’s look at catching them. The most common way of catching errors thrown by functions is by using the do-try-catch statement, here’s what that would look like using the same example:

func myMethod() {
    //...
    do {
        try writeToFile(named: "myFileName.txt")
        //Success code, after writing to the file
    } catch let error {
        print(error)
    }
}

Enter fullscreen mode Exit fullscreen mode

We can now catch any single Error (regardless of type) thrown in writeToFile so it can be handled in our catch block. We could also specify which errors exactly will be thrown so we can handle them more effectively – we’ll take a closer look at that later with some networking error examples.

Great. Next let’s see how Swift propagates errors.

Error propagation in Swift

As Swift propagates errors upwards, we can use caller methods to create centralized error handling in error prone scenarios, instead of handling them where they’re thrown. Let’s look at an example of upward propagation using the method structure below:

func myOuterMethod() {
    do {
        try myFirstLevelInnerMethod()
    } catch let error {

    }
}

func myFirstLevelInnerMethod() throws {
    try mySecondLevelInnerMethod()
}

func mySecondLevelInnerMethod() throws {
    try myThirdLevelInnerMethod()
}

func myThirdLevelInnerMethod() throws {
    //...
    throw FileError.permissionDenied
}

Enter fullscreen mode Exit fullscreen mode

Here you’ll notice that, while the Error is actually being thrown three levels deep, only the first method actually cares about handling it. How useful this is depends a lot on how our Swift code and project are structured.

Let’s explore this further by looking at some common error handling patterns.

Swift error handling patterns

There are many patterns and control statements we can use for error handling in Swift and we’re going to use our writeToFile example to demonstrate some of the most common methods.

Do-Try-Catch

As we mentioned earlier, using the do-try-catch statement is a great way to catch (and also handle) errors thrown down the chain. Here’s what that looks like in practice:

func myMethod() {
    //...
    do {
        try writeToFile(named: "myFileName.txt")
        //Success code, after writing to the file
    } catch let error {
        print(error)
    }
}

Enter fullscreen mode Exit fullscreen mode

Guards

Another effective way to handle errors in Swift are Guards. These are especially useful when we want to retain the current scope as any error-free code will continue to run in the scope provided while any errors will exit. To do this we just need to slightly change our writeToFile signature to, for example, return a boolean, as shown below:

func writeToFile(named filename: String) throws -> Bool {
    // Read file
    if fileNotFound {
        throw FileError.fileNotFound
    }

    // Write to file
    if permissionsDenied {
        throw FileError.permissionDenied
    }
} 

Enter fullscreen mode Exit fullscreen mode

Now we can simply write a guard statement to call that method, like this:

guard try writeToFile(named: "myFileName.txt") else {
    // Here we would need to exit scope, usually with a return
}

// on a successful file write, the code here would execute

Enter fullscreen mode Exit fullscreen mode

Swift Optionals

Another way to handle (or in this case ignore) errors thrown is by using a Swift optional on our tries. We’d do this simply by putting a ? in our try without a code path to actually handle the error afterwards, like this:

try? writeToFile(named: "myFileName.txt")

Enter fullscreen mode Exit fullscreen mode

Here, if the throwing function has any return value (like the boolean we added for the guard), then the value returned if an error is thrown will be nil.

Result type

While different than the other examples we’ve looked, the Result type is a great way to handle both successful and erroneous code paths. It’s really useful in scenarios such as network calls, since it provides clarity on exactly what’s happening.

To demonstrate we’ll use a different throwing function called parseDataToUser that we would imagine would parse a Data object and return a User if successful, as shown below:

func parseDataToUser(dataObject: Data, result: (Result<User, Error>) -> Void) {
    //here it would try to parse said data
        if anyError {
            result(.failure(myError))
        }    

        result(.success(myParsedUser))
}

Enter fullscreen mode Exit fullscreen mode

Now we could use this method with Result type support like this:

parseDataToUser(dataObject: ourDataObject) { result in
    switch result {
    case .success(let user):
        break
    case .failure(let error):
        break
    }
}

Enter fullscreen mode Exit fullscreen mode

As you can see, this is a very clear way to see both successful and erroneous code paths, and whenever possible, using an error case switch, is likely the best possible approach to handling errors in Swift. When using a switch case based on each error code, you can show a specific error message for each possible error, helping you or the user better understand the problem.

Fantastic. Now let’s bring it all together with an example project.

Example project: Simple network client

The best way to understand is to put the theory into practice with a working example and we’re going to setup a simple network client to demonstrate. Ready? Let’s go…

Initial setup

Our RequestClient will initially only have an init that accepts a URLSession, and if none is provided it uses the shared default as shown below:

final class RequestClient {

    private let session: URLSession

    init(_ session: URLSession = URLSession.shared) {
        self.session = session
    }
}

Enter fullscreen mode Exit fullscreen mode

Making requests

Now let’s add a method to allow us to make requests. We’ll accept the method type, defaulting to GET, the URL string, a body and headers. It will have a completion block that will use the previously seen Result type, to either return the data from our request or an error.

Our method is as follows:


func perform(method: String = "GET",
                 onURL urlString: String,
                 withBody body: [String: AnyObject] = [:],
                 withHeaders headers: [String: String] = [:],
                 completion: @escaping (Result<Data, Error>) -> Void) {

        guard let url = URL(string: urlString) else {
            completion(.failure()) // 1
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = method

        headers.forEach { (key: String, value: String) in
            request.setValue(value, forHTTPHeaderField: key)
        }

        do {
            let jsonifiedBody = try JSONSerialization.data(withJSONObject: body)
            request.httpBody = jsonifiedBody

        } catch let error {
            completion(.failure()) // 2
        }


        URLSession.shared.dataTask(with: request) { data, response, error in
            if error != nil {
                completion(.failure()) // 3
                return
            }

            if let response = response as? HTTPURLResponse, let data = data {
                switch response.statusCode {
                case 200...299:
                    completion(.success(data))
                case 400...499:
                    completion(.failure()) // 4
                case 500...599:
                    completion(.failure()) // 4
                default: 
                    completion(.failure()) // 4
            } else {
                    completion(.failure()) // 4
            }
        }
    }

Enter fullscreen mode Exit fullscreen mode

There are plenty of points at which it can fail and tell the method caller that an error occurred, these are:

  1. When trying to create a URL from the received String
  2. When turning the body into a jsonified body to be sent with the request
  3. When an error occurs with the request itself
  4. When any status code received is not in the acceptable range we’re expecting

💡 Learn more about status codes and their meanings here.

Now, as we already know all the possible errors our method may need to identify, we can now create our Error enum. For cases two and three, an error is already being thrown locally so our Error should take that as an associated value.

Learn more about enums and associated values in our article about Swift Enums


Write Better Code Using Swift Enums: A Detailed Guide

Similarly, since we already have error codes in scenario four, we can use those codes as associated error values, as shown here:

enum RequestError: Error {
    case invalidURL
    case invalidBodyFormat(error: Error)
    case requestError(error: Error)
    case invalidRequest(code: Int)
    case serverError(code: Int)
    case unknownError(code: Int)
    case wrongResponseFormat
}

Enter fullscreen mode Exit fullscreen mode

Now we have all our possible errors, we can see how our RequestClient looks when everything is in place:

final class RequestClient {

    private let session: URLSession

    init(_ session: URLSession = URLSession.shared) {
        self.session = session
    }


    func perform(method: String = "GET",
                 onURL urlString: String,
                 withBody body: [String: AnyObject] = [:],
                 withHeaders headers: [String: String] = [:],
                 completion: @escaping (Result<Data, Error>) -> Void) {

        guard let url = URL(string: urlString) else {
            completion(.failure(RequestError.invalidURL)) 
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = method

        headers.forEach { (key: String, value: String) in
            request.setValue(value, forHTTPHeaderField: key)
        }

        do {
            let jsonifiedBody = try JSONSerialization.data(withJSONObject: body)
            request.httpBody = jsonifiedBody

        } catch let error {
            completion(.failure(RequestError.invalidBodyFormat(error: error)) 
        }


        URLSession.shared.dataTask(with: request) { data, response, error in
            if error != nil {
                completion(.failure(RequestError.requestError(error: error!)))
                return
            }

            if let response = response as? HTTPURLResponse, let data = data {
                switch response.statusCode {
                case 200...299:
                    completion(.success(data))
                case 400...499:
                    completion(.failure(RequestError.invalidRequest(code: response.statusCode))) 
                case 500...599:
                    completion(.failure(RequestError.serverError(code: response.statusCode))
                default: 
                    completion(.failure(RequestError.unknownError(code: response.statusCode)) 
            } else {
                    completion(.failure(RequestError.wrongResponseFormat)) 
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

While we’re using it as an example, it is a legitimate request maker class that you could use, so long as you make sure to assign the errors with names errors that make sense in your own scope.

And that’s it – our client is now ready to use and will run like this:

let client = RequestClient()
client.perform(method: "GET",
               onURL: "<https://httpbin.org/get>") { [weak self] result in
    switch result {
    case .success(let data):
        // handle our data
    case .failure(let error):
        // handle our error here. 
        // Show an alert to the user if appropriate. 
        // Log the error into our favourite logging framework, 
        // like BugFender.
    }
}

Enter fullscreen mode Exit fullscreen mode

To sum up

Effective Swift error handling is crucial to creating stable and reliable applications as it ensures unexpected situations are responded to properly avoiding crashes. Along with improving user experience, good error handling practices make it easier to identify, track and fix bugs. If later, your pair your app with a tool similar to Bugfender, that can notify you about any runtime error and can help you on managing errors, you will be in a really good place to achieve a very stable application over time.

In this article we’ve looked at some of the tools available in Swift to help us handle errors effectively and we’ve seen how errors are declared, thrown and caught.

We also wrote a fully usable RequestClient that can handle errors or propagate them upwards so the caller can handle them.

Hopefully you now understand how to better write a proper error handling mechanism and feel confident to try these approaches in your own Swift projects.

Top comments (0)