In this article we’ll focus on handling errors in Swift.
Proper error handling is crucial to writing effective production-level code that will get shipped with real apps. Often, while working on an app’s features, developers tend to focus on the “HAPPY PATH” (see the diagram below)—but in reality, we can’t ship any app without properly addressing different error scenarios that might occur in our code flows.
Error handling is important because any input from the outside world cannot be trusted—the app might be getting attacked by a hacker or malicious user. Or in most scenarios, we might need to handle unexpected events or data.
Dependent types to prevent invalid states
Swift allows us to work with dependent types, which can help prevent invalid states in our program. These types can also be used to distinguish between validated input and unvalidated input.
For example, if we have one Class type as User, and we don’t let the system create a user without an email address, then we can rest assured that we’ll never have a User type in our program without an email address.
Segregating and designating errors
We need to separate different kinds of errors in our program and use the most suitable one for each respective job. Let’s have a look at the following list of errors:
- Logic or programming error: Can be caught early in development with preconditions and assertions. Often it’s better to create a fatal error rather than silently sweep the error under the rug.
- Optionals to represent failures: Works when there’s a single cause for an error. For example, while looking up a key in a dictionary, there’s only one type of error, i.e. the key isn’t present. This makes it a perfect choice for an optional return value.
- Using throws: At times, we tend to abuse optionals and just fail if anything goes wrong in the program. A better choice is to throw a different error code, depending on the error itself.
- Implementing result type: While making API calls, we might expect either success or failure, and result type can help us achieve that. Swift 5 brings result type into the standard library.
Error handling using optionals and failable initializers
In this section, we’ll discuss optionals and failable initializers as a form of error handling. We’ll work with the following example to discover this:
struct EmployeeID : Hashable {
var id: Int
init(_ employeeID: Int) {
precondition(EmployeeID.isValid(employeeID), "INvalid EmployeeID")
self.id = employeeID
}
static func isValid(_ input: Int) -> Bool {
// Checks the employee Id is more than 4 digits and less than 7
return input < 1000000 && input > 1000
}
}
Here, we have an EmployeeID type, which has a static helper function that checks if an employee ID is created within the rules of the company (Rule: the employee ID has to be greater than 4 but less than 7 digits). The struct has a precondition in the initializer to check the validity of employeeID.
Problem: It seems pretty obvious that the initializer isn’t a great place for a precondition, as we’ll always have to rely on the user checking the validity of the number before passing it in—otherwise, the app will crash.
Solution: A solution for the problem mentioned above would be to make a failable initializer and name the init parameter as raw to emphasize that it might not be the final value and is coming from an unchecked source:
struct EmployeeID : Hashable {
var id: Int
init?(_ raw: Int) {
guard EmployeeID.isValid(raw) else {
return nil
}
self.id = raw
}
// Same as above
}
Swift try/catch
When try, catch, and throw were introduced in Swift 2.0, many developers were worried that it might mean exceptions similar to those in C++.
The compiler also does some checking to make sure that we’re properly responding to the possibility of an error.
Basics of error handling
To declare an error, we need to conform to the Swift error type. This is usually done with an enum, where each case represents a different error. We can use associated values in the enum to communicate additional information. Any function that can encounter an error code needs to be declared with throws in addition to the type it normally returns, and if we call the function, it must be marked with try.
A catch block automatically assigns a thrown error to the variable error, although this name can be customized. Let’s work with an example to understand it better:
enum CalculationError: Error {
case divideByZero
}
func divide(_ numerator: Double, _ denominator: Double) throws -> Double {
if denominator == 0 {
throw CalculationError.divideByZero
}
return numerator / denominator
}
Here we have defined an Error enum CalculationError with a special case divideByZero.
Function divide throws a divideByZero error if the denominator is zero. Let’s see how to handle the error when the function is invoked :
do {
print("Division Result is", try divide(999, 0))
} catch {
print("Division Result is", error, "error")
}
When we call this function we need to handle the error by making use of the do statement as shown in the code and mark the function call with try. Once the error propagates through the flow, it is trapped in the catch block which is then used to handle the error object. As in this case we have simply printed the error to console. Hence, the result of executing above lines of code will print following to the console:
Custom errors with meaningful information
In this section we are going to throw and catch useful error information. We will work with the following example to see this in action:
enum SimpleCustomError: Int, Error {
case smallError = 1
case bigError
}
do {
throw SimpleCustomError.smallError
} catch {
print("Caught error =", error)
}
We have declared a CustomError represented by an Int and in the subsequent code flow we throw the error and catch it.
The code is very simple to follow and the result of executing this code will print following to the console:
Let’s now define an Error with more meaningful information:
enum DescriptiveCustomError: Error {
case destructiveError(String)
}
We’ve defined DescriptiveCustomError with some meaning information using a String, and now we can use pattern matching to catch respective errors as follows:
do {
throw SimpleCustomError.bigError
} catch (let error as SimpleCustomError) {
print("Caught SimpleCustomError =", error)
} catch DescriptiveCustomError.destructiveError(let message) {
print("Caught DescriptiveCustomError with message =", message)
} catch {
print(error)
}
This code uses pattern matching to catch the right error, and in this case, we’ve caught a SimpleCustomError.
The result of executing this code will print following to the console:
If we try to throw an error of type DescriptiveCustomError, we get the following:
do {
throw DescriptiveCustomError.destructiveError("Bail me out!!!")
} catch (let error as SimpleCustomError) {
// Same as above
}
The result of executing the code will print following to the console:
Actionable information
Since Swift allows us to create custom errors and pass them around, we should create “good errors” that pass actionable information. An example of such good errors can be found in the Swift native library Codeable, which defines an error called DecodingError.typeMismatch. Let’e have a look at the information that it passes around by having a look at its definition:
As we can see, this error has a context that has a lot of information about what went wrong during coding. It also uses associated values to expose the correct information. It also creates a number of static helper methods for creating these errors.
Conclusion
In this article we discussed error handling in Swift. Error handling is important while writing production-level code. It’s an important habit that you as a developer should adopt as second nature.
It’s very important to use right kind of error for any problem at hand; for instance, if you’re dealing with a logical problem, you should use a pre-condition, fatal error, or force-unwrap. Sometimes using optionals will suffice, especially if you combine it with simple dependent types that have a clear defined failure.
Try/catch is an amazing system for making sure that you’re handling the error scenarios, i.e. the non-happy path. This is really handy because the compiler forces you to think about the error and how you want to deal with it. Be careful while using try? or try! in the code.
Try to use meaningful errors that can help you provide important information to your app’s users. In forthcoming blogs, we’ll look closer at Swift 5 Result Type and Handling Objective C errors.
For other updates you can follow me on Twitter on my twitter handle @NavRudraSambyal
Thanks for reading, please share it if you found it useful 🙂
Comments 0 Responses