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…
Table of Contents
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
}
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
}
}
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)
}
}
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
}
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)
}
}
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
}
}
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
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")
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))
}
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
}
}
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
}
}
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
}
}
}
There are plenty of points at which it can fail and tell the method caller that an error occurred, these are:
- When trying to create a
URL
from the receivedString
- When turning the body into a jsonified body to be sent with the request
- When an error occurs with the request itself
- 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
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
}
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))
}
}
}
}
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.
}
}
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.