8.5 Custom conditions

One of the challenges of error handling in R is that most functions generate one of the built-in conditions, which contain only a message and a call. That means that if you want to detect a specific type of error, you can only work with the text of the error message. This is error prone, not only because the message might change over time, but also because messages can be translated into other languages.

Fortunately R has a powerful, but little used feature: the ability to create custom conditions that can contain additional metadata. Creating custom conditions is a little fiddly in base R, but rlang::abort() makes it very easy as you can supply a custom .subclass and additional metadata.

The following example shows the basic pattern. I recommend using the following call structure for custom conditions. This takes advantage of R’s flexible argument matching so that the name of the type of error comes first, followed by the user facing text, followed by custom metadata.

abort(
  "error_not_found",
  message = "Path `blah.csv` not found", 
  path = "blah.csv"
)
#> Error: Path `blah.csv` not found

Custom conditions work just like regular conditions when used interactively, but allow handlers to do much more.

8.5.1 Motivation

To explore these ideas in more depth, let’s take base::log(). It does the minimum when throwing errors caused by invalid arguments:

log(letters)
#> Error in log(letters): non-numeric argument to mathematical function
log(1:10, base = letters)
#> Error in log(1:10, base = letters): non-numeric argument to mathematical
#> function

I think we can do better by being explicit about which argument is the problem (i.e. x or base), and saying what the problematic input is (not just what it isn’t).

my_log <- function(x, base = exp(1)) {
  if (!is.numeric(x)) {
    abort(paste0(
      "`x` must be a numeric vector; not ", typeof(x), "."
    ))
  }
  if (!is.numeric(base)) {
    abort(paste0(
      "`base` must be a numeric vector; not ", typeof(base), "."
    ))
  }

  base::log(x, base = base)
}

This gives us:

my_log(letters)
#> Error: `x` must be a numeric vector; not character.
my_log(1:10, base = letters)
#> Error: `base` must be a numeric vector; not character.

This is an improvement for interactive usage as the error messages are more likely to guide the user towards a correct fix. However, they’re no better if you want to programmatically handle the errors: all the useful metadata about the error is jammed into a single string.

8.5.2 Signalling

Let’s build some infrastructure to improve this situation, We’ll start by providing a custom abort() function for bad arguments. This is a little over-generalised for the example at hand, but it reflects common patterns that I’ve seen across other functions. The pattern is fairly simple. We create a nice error message for the user, using glue::glue(), and store metadata in the condition call for the developer.

abort_bad_argument <- function(arg, must, not = NULL) {
  msg <- glue::glue("`{arg}` must {must}")
  if (!is.null(not)) {
    not <- typeof(not)
    msg <- glue::glue("{msg}; not {not}.")
  }
  
  abort("error_bad_argument", 
    message = msg, 
    arg = arg, 
    must = must, 
    not = not
  )
}

If you want to throw a custom error without adding a dependency on rlang, you can create a condition object “by hand” and then pass it to stop():

stop_custom <- function(.subclass, message, call = NULL, ...) {
  err <- structure(
    list(
      message = message,
      call = call,
      ...
    ),
    class = c(.subclass, "error", "condition")
  )
  stop(err)
}

err <- catch_cnd(
  stop_custom("error_new", "This is a custom error", x = 10)
)
class(err)
err$x

We can now rewrite my_log() to use this new helper:

my_log <- function(x, base = exp(1)) {
  if (!is.numeric(x)) {
    abort_bad_argument("x", must = "be numeric", not = x)
  }
  if (!is.numeric(base)) {
    abort_bad_argument("base", must = "be numeric", not = base)
  }

  base::log(x, base = base)
}

my_log() itself is not much shorter, but is a little more meangingful, and it ensures that error messages for bad arguments are consistent across functions. It yields the same interactive error messages as before:

my_log(letters)
#> Error: `x` must be numeric; not character.
my_log(1:10, base = letters)
#> Error: `base` must be numeric; not character.

8.5.3 Handling

These structured condition objects are much easier to program with. The first place you might want to use this capability is when testing your function. Unit testing is not a subject of this book (see R packages for details), but the basics are easy to understand. The following code captures the error, and then asserts it has the structure that we expect.

library(testthat)

err <- catch_cnd(my_log("a"))
expect_s3_class(err, "error_bad_argument")
expect_equal(err$arg, "x")
expect_equal(err$not, "character")

We can also use the class (error_bad_argument) in tryCatch() to only handle that specific error:

tryCatch(
  error_bad_argument = function(cnd) "bad_argument",
  error = function(cnd) "other error",
  my_log("a")
)
#> [1] "bad_argument"

When using tryCatch() with multiple handlers and custom classes, the first handler to match any class in the signal’s class vector is called, not the best match. For this reason, you need to make sure to put the most specific handlers first. The following code does not do what you might hope:

tryCatch(
  error = function(cnd) "other error",
  error_bad_argument = function(cnd) "bad_argument",
  my_log("a")
)
#> [1] "other error"

8.5.4 Exercises

  1. Inside a package, it’s occasionally useful to check that a package is installed before using it. Write a function that checks if a package is installed (with requireNamespace("pkg", quietly = FALSE)) and if not, throws a custom condition that includes the package name in the metadata.

  2. Inside a package you often need to stop with an error when something is not right. Other packages that depend on your package might be tempted to check these errors in their unit tests. How could you help these packages to avoid relying on the error message which is part of the user interface rather than the API and might change without notice?