Error Handling
How an application deals with errors and unexpected values is arguably just as important as how it deals with valid results. Let’s take a look at a few key techniques that can help us provide a better user experience whenever an error was encountered within our code.
Swift provides a native way to define and handle errors using the Error
protocol. Conforming to it doesn’t require us to add any specific properties or methods, so we can make any type conform to it with ease — such as this enum containing a few different errors that could be encountered when validating a String
value:
enum ValidationError: Error {
case tooShort
case tooLong
case invalidCharacterFound(Character)
}
Using the above error enum, we can now write a simple function that validates that a given username
isn’t too long or short, and that it doesn’t contain any non-letter characters. To do that, we’ll mark our function as being able to throw errors using throws
, and use the throw
keyword to trigger an error in case a validation requirement wasn’t met — like this:
func validate(username: String) throws {
guard username.count > 3 else {
throw ValidationError.tooShort
}
guard username.count < 15 else {
throw ValidationError.tooLong
}
for character in username {
guard character.isLetter else {
throw ValidationError.invalidCharacterFound(character)
}
}
}
Since we marked the above function with throws
, we’re now required to prefix any call to it with the try
keyword — which in turn forces us to handle any errors thrown from it (or to convert its return value into an optional using try?
). For example, here we’re using our function to validate a username that the user just picked, and if the validation passed (no error was thrown), then we’ll continue by submitting that username to our server — otherwise, we display the error that was encountered using a UILabel
:
func userDidPickName(_ username: String) {
do {
try validate(username: username)
// If we reach this point in the code, then it means
// that no error was thrown, and the validation passed.
submit(username)
} catch {
// The variable ‘error’ is automatically available
// inside of ‘catch’ blocks.
errorLabel.text = error.localizedDescription
}
}
However, if we run the above code with an invalid username as input (such as “john-sundell”), we’ll end up with a quite obscure error message displayed in our errorLabel
:
The operation couldn’t be completed. (App.ValidationError error 0.)
That isn’t great, since it’ll probably end up confusing the user more than anything else. There’s no actionable information, and we’re also exposing implementation details (such as the name of the error type) to the user.
Thankfully, that’s easily fixable — by enabling our error type to be localized. To do that, let’s extend ValidationError
to conform to LocalizedError
, which is a specialized version of the Error
protocol. By doing that, and implementing its errorDescription
property — we can now return an appropriate, localized message for each error case:
extension ValidationError: LocalizedError {
var errorDescription: String? {
switch self {
case .tooShort:
return NSLocalizedString(
"Your username needs to be at least 4 characters long",
comment: ""
)
case .tooLong:
return NSLocalizedString(
"Your username can't be longer than 14 characters",
comment: ""
)
case .invalidCharacterFound(let character):
let format = NSLocalizedString(
"Your username can't contain the character '%@'",
comment: ""
)
return String(format: format, String(character))
}
}
}
With the above change in place, our validation error from before will now get displayed in a much more user-friendly way:
Your username can't contain the character '-'
Much better 👍. And the good news is that we can apply much of the same technique when dealing with asynchronous errors as well. So far, we’ve only been working with errors and throwing functions in a completely synchronous manner — and the do, try, catch
pattern used above is excellent for that — but when it comes to asynchronous code, errors are often passed to a completion handler, rather than thrown.
For example, let’s say that we wanted to make our validate
function asynchronous — perhaps to be able to do network-bound validation, or to execute more complex rules on a background thread. To make that happen, we’ll transform our function signature to instead look like this:
func validate(username: String,
then handler: @escaping (ValidationError?) -> Void) {
...
}
Since errors are now passed as optionals to our handler
closure, we’ll have to use a slightly different tactic to catch them. Thankfully, it’s just a matter of unwrapping that optional, and using the same localizedDescription
property to access our localized error messages — rather than using a catch
block:
func userDidPickName(_ username: String) {
validate(username: username) { error in
if let error = error {
errorLabel.text = error.localizedDescription
} else {
submit(username)
}
}
}
Taking a little bit of extra time to add proper error handling to an app can really elevate it in terms of perceived quality. No-one likes to be stuck on a screen with an obscure error message that doesn’t really say anything, and that doesn’t offer any form of suggested actions to rectify the error. By adding localization to our error types, and a bit of code to handle and display those errors, we can hopefully keep our users happy — even when something does go wrong.
Thanks for reading! 🚀