paint-brush
Effective Error Handling in iOSby@lucasoliveira
852 reads
852 reads

Effective Error Handling in iOS

by Lucas OliveiraFebruary 12th, 2021
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This article explains why you should handle errors on your app. It also gives a rule of thumb on when treating errors, and gives some practical improvements you could apply. With a monitoring tool, you can have useful data to discover, understand, and prioritize errors. Having rich errors will also help you to create insightful dashboards and precise alerts on your monitoring tool. It is important that the alerts are sent to a channel where all the devs have access to the logs to get any critical scenario. With a good tool setup, it is time to bring a monitoring culture to the team.

People Mentioned

Mention Thumbnail

Companies Mentioned

Mention Thumbnail
Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - Effective Error Handling in iOS
Lucas Oliveira HackerNoon profile picture

We want our software to be error-proof, but in reality, error scenarios will always exist.

So this article's objective is to explain why you should handle errors on your app, give you a rule of thumb on when treating errors, and give some practical improvements you could apply on your app.

So, why handling errors?

Finding errors asap

Worse than finding a bug on your production app is learning that the bug you just found is around for several releases.

Below is the recipe of failure to ship a hard to catch bug.

func callMerchant(with telefoneNumber: String?) {
    guard let phone = telefoneNumber else { return }
    // Calling a phone number code goes here
}

If for some reason this function is ever invoked with a

nil
telefoneNumber
, it will exit without trying to make the phone call, and this could lead to a useless call button on your user interface, that does nothing when pressed.

Improving User Experience (UX)

It might be really frightening to a user with no technology background to be prompted with an error dialog full of tech words and error codes, especially if he just made a critical action like a purchase. Take a look at the picture on the left.

On the other hand, the picture to the right explains what happened and how the user could proceed.

Recovering from an error state to a success

With a really well-crafted error recovering strategy you could even recover a user that got in an error state, to the main flow of your application that will lead to a goal (like making a purchase).

Recovering from errors is not only important for the tech team, but it is also beneficial for the business as a whole. Commonly, digital products lose some conversion percentual points due to techinical issues. And good error handling might mitigate this issue.

The example above gives clear instructions and even some shortcuts on how to get out of this error and try another product.

Rule of thumb to handling errors

Maybe your App is nothing like any of the examples I gave so far, and you are not sure where you could improve error handling in your app. So you could follow this rule of thumb to know where you should consider handling errors.

Consider handling errors every time you...

  • ...make a request to an external source (networking)
  • ...capture user input
  • ...encode or decode some data
  • ...escape a function prior to its full execution (early return)

Practical improvements for your App

Monitoring tool

This is the most important improvement that you could do! With a monitoring tool, you can have useful data to discover, understand, and prioritize errors.

By understanding the volumetry of an error, you could decide between adding a fix to the next version, creating a new version as soon as possible just to fix that error (hotfix), or turn off some remote configuration to disable the problematic feature.

There are several monitoring tools available on the market, like Dynatrace or New Relic. The monitoring tool that I use at iFood is Logz.io. It provides all the utility that we need to keep track of error logs:

  • Logs over time
  • Querying for specific logs
  • Configuring alerts to send to Slack
  • Dashboard creation

With a good tool setup in the project, it is time to bring a monitoring culture to the team. You could start establishing some new tasks that should be done at every new feature development.

  • Map all error cases
  • Create logs for the error cases
  • Create alerts for the logs to get any critical scenario. It is important that the alerts are sent to a channel where all the devs have access.
  • Create a Dashboard containing all the logs for that feature
  • Monitor the dashboard periodically. You could make a recurrent event on the calendar to be reminded.

Swift's Error protocol

Swift's error protocol allows you to create expressive errors that will give you useful information to find and act on a possible issue. Having rich errors will also help you to create insightful dashboards and precise alerts on your monitoring tool.

Below, there is a simple example of how to use it in an enumerator.

enum SimpleError: Error {
    case generic
    case network(payload: [String: Any])
}

In this example, we will capture the payload of the request that caused a network error. Doing so, we could look for patterns on the payloads, and understand what really causes the error.

Disclaimer: if you want to do an error treatment like that one where you send the payload from a network request, you must mask any user sensitive data.

You could also conform to this protocol in a

struct
so you can have as much information as you need on the error. This is useful when you want to custom tailor an error for a very specific scenario. 


struct StructError: Error {
    enum ErrorType {
        case one
        case two
    }
    let line: Int
    let file: String
    let type: ErrorType
    let isUserLoggedIn: Bool
}
// ...
func functionThatThrowsError throws {
    throw StructError(line: 53, file: "main.swift", type: .one, isUserLoggedIn: false)
}

Later on your monitoring tool, you could create a dashboard comparing the volumetry when the user has logged in again when it is not logged in, so you can understand if this is a relevant factor that leads to the error.

And if you want the error to also contain localized human-readable messages to display, you could also conform to

LocalizedError
as shown below.

enum RegisterUserError: Error {
    case emptyName
    case invalidEmail
    case invalidPassword
}
extension RegisterUserError: LocalizedError {
    // errorDescription is the one that you get when using error.localizedDescription
    var errorDescription: String? {
        switch self {
        case .emptyName:
            return "Name can't be empty"
        case .invalidEmail:
            return "Invalid email format"
        case .invalidPassword:
            return "The password must be at least 8 characters long"
        }
    }
}

Now if an error of type

RegisterUserError
happens, you could display
error.localizedDescription
to the user.

Don't use
nil
as an error

Take a look at the function below, it is uncanny how familiar this code is.

func getUserPreferences() -> UserPreferences? {
    let dataFromKey = UserDefaults.standard.data.(forKey: "user_preferences")
    guard let data = dataFromKey else { return nil }
    let decoder = JSONDecoder()
    let userPreferences = try? decoder.decode(UserPreferences.self, from: data)
    return userPreferences
}

At first glance, pretty standard implementation. Nothing wrong with that. But if this code ever returns

nil
, how would you know if there are no
UserPreferences
set yet or if there is something wrong with our encoding or decoding of this object?

If we want to ship this code to production it would be really nice that the caller of this function would create and save a new

UserPreferences
with default preferences if there are none set, or log an error to our monitoring tool if the decoding failed, so we could investigate and fix it.

Returning

nil
when some error occurs really limits the options you have to handle it.

You could instead make the function throwable, declaring it with

throws
and removing the optional mark
?
from the decoder's
try
. If you never used
throws
in Swift, I strongly recommend you read this article from Sundell, as I'll not cover the basics on what it is and how it works.

func getUserPreferences() throws -> UserPreferences {
    let dataFromKey = UserDefaults.standard.data.(forKey: "user_preferences")
    guard let data = dataFromKey else {
        throw UserPreferencesError.noUserPreferences
    }
    let decoder = JSONDecoder()
    let userPreferences = try decoder.decode(UserPreferences.self, from: data)
    return userPreferences
}

Note: on the example above, it would be correct to return

nil
on the
else
clause of the
guard
, as
nil
is in fact the representation of the absence of value. I went all-in on throws just to illustrate better how it could be used.

But what if the errors happen in an asynchronous context? Is it time to return

nil
? Still, not ideal, instead, we could use other Swift's nice feature for error handling that suits really well in asynchronous contexts:
Result
.

Result
is an
enum
with the form
Result<Success, Failure> where Failure: Error
that has a
success
case with the result of the request as an associated value, and a
failure
case that brings an
Error
associated. Again, if you never heard of
Result
before, I strongly recommend this quick read from hacking with swift.

If our

getUserPreferences
fetch its data from a server instead of
UserDefaults
, we could rewrite it like the example below.

func getUserPreferences(userID id: String, completion: @escaping (Result<UserPreferences, Error>) -> Void) {
    Network.request(.userPreferences(userID: id)) { result in
      switch result {
      case .success(let data):
          do {
              let decoder = JSONDecoder()
              let userPreferences = try decoder.decode(UserPreferences.self, from: data)
              completion(.sucess(userPreferences))
          } catch {
              completion(.failure(error))
          }
      case .failure(let error):
          completion(.failure(error))
      }
    }
}

This way, the caller function could differentiate an encoding error from a network error, and log it.

Separate error handling from the actual functionality

If you are familiar with the SOLID principles, you know the importance of the Single Responsibility Principle (SRP). The SRP says that our software units should have a single responsibility. What is a responsibility depends on the size of the software unit, the responsibility that a function can have is more narrow than the responsibility that a class or a module could handle.

Take a look on the example below:

func registerUser(_ user: User) throws {
    guard user.name.isEmpty == false else {
        throw RegisterUserError.emptyName
    }
    guard isValid(email: user.email) else {
        throw RegisterUserError.invalidEmail
    }
    guard isValid(password: user.password) else {
        throw RegisterUserError.invalidPassword
    }
    /*
        Code that registers a user goes here
    */
}

The function

registerUser
breaks SRP and it is not easy to read because the code that actually register the user is at the end of the function, with all the validation rules first.

We could greatly improve this function by separating the error handling responsibility from the user registering responsibility.

func registerUser(_ user: User) throws {
    try validateUser(user)
    /*
        Code that registers a user goes here
    */
}

func validateUser(_ user: User) throws {
    guard user.name.isEmpty == false else {
        throw RegisterUserError.emptyName
    }
    guard isValid(email: user.email) else {
        throw RegisterUserError.invalidEmail
    }
    guard isValid(password: user.password) else {
        throw RegisterUserError.invalidPassword
    }
}

Conforming to the SRP made this function that much easier to read.

Recap

  • 😌 Don't leave errors unhandled, your users will appreciate it.
  • ☁️ Use a monitoring tool on your project.
  • ❤️ Use Swift's
    Error
    protocol to get expressive and useful errors.
  • 🙅‍♂️ Don't use
    nil
    as an error.
  • 👨‍💻 Separate error validation and treatment from the actual functionality.

What's next?

If you are interested in error handling, chapter 6 of Clean Code from Robert C. Martin (a.k.a Uncle Bob) is a must-read. This chapter was the biggest reference to this article, and it was what really drew my attention to the importance of handling errors. I consider this book a must-have for any software engineer, so you might as well get it on Amazon.

If you want to know more about SRP and the other SOLID principles, this article at hackernoon is a great light start. And This repo from ochococo gives nice and easy examples and has links to in-depth articles that are really good reading.

Thank you for reading! I hope this is was insightful and that you can apply those ideas on your projects. Take care and good error handling!

Previously published at https://lucasoliveira.tech/posts/improving-error-handling-in-your-app