Conditions

Applying R to Lifestyle and Brain Health Research

Brian C. Helsel, PhD

University of Kansas Medical Center

September 23, 2026

Introduction

Provides a paired set of tools that allows the author of a function to indicate that something unusual is happening and a user to deal with it. The author signals conditions with functions like stop() (for errors), warning() (for warnings), and message() (for messages).

The user can handle the signals with functions like tryCatch() and withCallingHandlers().

Signalling conditions

  • Errors indicate that there is no way for a function to continue and execution must stop
  • Warnings show that something has gone wrong but the function could at least partially recover
  • Messages inform users that some action has been performed on their behalf

Conditions are displayed prominently with errors always starting with Error and warnings starting with Warning.

stop("This is an error")
#> Error: This is an error
warning("Here is a warning...")
Warning: Here is a warning...
message("and a message")
and a message

Errors

Errors are signalled, or thrown using stop() Adding call. = FALSE removes the function call which is typically not useful and can be obtained from traceback. This can be accomplished automatically with rlang::abort.

f <- function() g()
g <- function() h()
h <- function() stop("Here is an error!", call. = FALSE)

f()
#> Error: Here is an error!

The best error messages tell you what is wrong and provides direction toward fixing the problem. Writing good error messages is challenging because it is hard to imagine how the user is thinking incorrectly about your code.

You can visit the tidyverse style guide for helpful tips in writing error messages.

Warnings

Warnings signal that something has gone wrong, but the code recovered and was able to continue. You can have multiple warnings within a function.

fw <- function() {
  cat("1\n")
  warning("Warning #1", call. = FALSE)
  cat("2\n")
  warning("Warning #2", call. = FALSE)
}

fw()

#> 1
#> 2
#> Warning messages:
#> 1: Warning #1
#> 2: Warning #2

Warnings are cached and printed only when a function exists, but you can control this behavior with the warn option.

  • options(warn = 0) is the default behavior and prints warnings at the end.
  • options(warn = 1) will make the warnings appear immediately.
  • options(warn = 2) will turn the warnings into errors (helps with debugging a warning).

It is difficult to know when to use warnings. Warnings are often missed if there is a lot of other output and restraining use of warnings is advised. Warnings are appropriate when:

  • You deprecate a function and want to allow older code to run but encourage users to switch to the newer function
  • You are reasonable certain you can recover but not fix a problem

Messages

Provide information to let the user know you have done something on their behalf. Good messages provide useful information so the user knows what is happening, but not too many details that the user feels overwhelmed. Messages are recommended when:

  • Providing a default argument in a function that requires some computation and you want to let the user know what value was used (e.g., ggplot2 reports the number of bins if binwidth is missing)
  • Using functions that are called for their side-effects and would otherwise be silent (e.g., writing files to disk or calling a web API)
  • Starting a long, multi-step running process
  • Providing a message when your package is loaded (e.g., .onAttach())

The results of the message() and cat() functions are similar but their purposes are different. Use cat when the user asks for a message to be printed to the console and message when the developer elects for a message to be printed.

Ignoring Conditions

A simple way to handle conditions in R is to ignore them. This can be done using try for errors, suppressWarnings for warnings, and suppressMessages for messages.

The try() function allows execution to continue after an error has occurred.

f1 <- function(x) {
  try(log(x))
  10
}

f1()

#> Error in log(x) : argument "x" is missing, with no default
#> [1] 10

By doing assignment within the call, you can define a default value for use if the code does not work. This works because the argument is evaluated in the calling environment and not the function.

default <- NULL

try(default <- read.csv("this-file-does-not-exist.csv"), silent = TRUE)

Both suppressWarnings() and suppressMessages() suppress all warnings and messages.

suppressWarnings({
  warning("This might be a problem!")
  1
})
#> [1] 1

suppressMessages({
  message("Greetings!")
  2
})
#> [1] 2

Handling Conditions

Each signalling condition has a default behavior:

  • Errors stop execution and return to the top level
  • Warnings are captured and displayed in aggregate
  • Messages are immediately shown

Condition handlers allow us to temporarily override or supplement the default behavior. We can use tryCatch and withCallingHandlers to register handlers.

tryCatch(
  error = function(condition) {
    # code to run when an error is thrown
  },
  # code to run while handlers are active
)

withCallingHandlers(
  warning = function(condition) {
    # code to run when a warning is signalled
  },

  message = function(condition) {
    # code to run when a message is signalled
  },

  # code to run while handlers are active
)

Condition Objects

Viewing a condition object that is signalled can be done with rlang::catch_cnd. Built-in conditions have two elements:

  • A message containing the text that should be displayed to the user
  • A call that triggered the condition
cnd <- rlang::catch_cnd(stop("Here is an error!"))
str(cnd)

#> List of 2
#>  $ message: chr "Here is an error!"
#>  $ call   : language force(expr)
#>  - attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

# Extract the message
conditionMessage(cnd)

# Extract the call
conditionCall(cnd)

Exiting Handlers

The function tryCatch can be used to handle error conditions. You can override the default error behavior (e.g., return NA instead of an error) and code executes normally if no error exists.

f2 <- function(x) {
  tryCatch(
    error = function(cnd) NA,
    log(x)
  )
}

f2("x")
#> [1] NA

f2(10)
#> [1] 2.302585

It is called an exiting handler because control passes to the handler after the error occurs and never returns to the original code.

The handler functions are called with a single argument (i.e., the condition object). This object becomes more useful when you make your own custom conditions.

tryCatch(
  error = function(cnd) {
    paste0("--", conditionMessage(cnd), "--")
  },
  stop("Here is an error")
)

#> [1] "--Here is an error--"

The tryCatch function also has a finally argument to provide a block of code that runs regardless of whether the initial expression is successful. This could be useful for clean up (e.g., deleting files) or closing connections.

path <- tempfile()

tryCatch(
  {
    writeLines("Hi", path)
  },
  finally = {
    unlink(path)
  }
)

Calling handlers

Unlike exiting handlers, calling handlers like withCallingHandlers allow code execution to continue normally once the handler returns, making a more natural pairing with the non-error conditions (i.e., message, warning). Notice how the execution of the code below differs:

tryCatch(
  message = function(cnd) cat("Here is a message!\n"),
  {
    message("Is someone available?")
    message("Why, yes! How can I help?")
  }
)

#> Here is a message!

withCallingHandlers(
  message = function(cnd) cat("Here is a message!\n"),
  {
    message("Is someone available?")
    message("Why, yes! How can I help?")
  }
)

#> Here is a message!
#> Is someone available?
#> Here is a message!
#> Why, yes! How can I help?

Handlers are applied in order so you do not need to worry about infinite loops, but you may want to think through the order if using multiple handlers.

withCallingHandlers(
  message = function(cnd) message("Second message"),
  message("First message")
)

#> Second message
#> First message

A unique side-effect of calling handlers is the ability to muffle the signal. A condition will continue to propagate to the parent handlers all the way to the parent or exiting handler.

# Bubbles up to the default handler which generates the message
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Greetings!")
  )
)

#> Level 1
#> Level 2
#> Greetings!

# Muffles the default handler

withCallingHandlers(
  message = function(cnd) {
    cat("Level 2\n")
    rlang::cnd_muffle(cnd)
  },
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Greetings!")
  )
)

#> Level 1
#> Level 2

Custom Conditions

R has the ability to create custom conditions containing additional metadata. Using rlang::abort allows you to apply a custom .subclass and additional metadata, guiding the user toward a solution to the problem.

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

my_log(letters)
#> Error in `my_log()`: `x` must be a numeric vector; not character.

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

Signalling

Let’s improve our my_log error handling by making it more generalized, including a nice error message with the glue R package for the user and more metadata for the developer.

# An example of doing this in Base R is in Advaned R on page 191

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}")
  }
  rlang::abort(
    "error_bad_argument",
    message = msg,
    arg = arg,
    must = must,
    not = not
  )
}

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(letters)
#> Error in `abort_bad_argument()`: ! `x` must be numeric; not character

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

This structure makes programming more efficient and can be used in a wide range of scenarios including unit testing in package development.

Applications

Some common applications of the condition system in R:

  1. Failure value
  2. Success and failure values
  3. Resignal
  4. Record
  5. No default behavior

Failure Value

Return a default value if an error occurs

fail_with <- function(expr, value = NULL) {
  tryCatch(
    error = function(cnd) value,
    expr
  )
}

fail_with(log(10), NA_real_)
#> [1] 2.302585

fail_with(log("x"), NA_real_)
#> [1] NA

A more sophisticated application is base::try():

try2 <- function(expr, silent = FALSE) {
  tryCatch(
    error = function(cnd) {
      msg <- conditionMessage(cnd)
      if (!silent) {
        message("Error: ", msg)
      }
      structure(msg, class = "try-error")
    },
    expr
  )
}

try2(1)
#> [1] 1

try2(stop("Hello!"))
#> Error: Hello!
#> [1] "Hello!"
#> attr(,"class")
#> [1] "try-error"

try2(stop("Hello!"), silent = TRUE)
#> [1] "Hello!"
#> attr(,"class")
#> [1] "try-error"

Success and Failure Values

An extension of the failure value by returning a different value if the code evaluates successfully. This requires an evaluation of the code supplied by the user.

We can use this to determine if an expression fails:

does_error <- function(expr) {
  tryCatch(
    error = function(cnd) TRUE,
    {
      expr
      FALSE
    }
  )
}

does_error(log(10))
#> [1] FALSE

does_error(log("x"))
#> [1] TRUE

Or to capture any condition as seen in rlang::catch_cnd

catch_cnd <- function(expr) {
  tryCatch(
    condition = function(cnd) cnd,
    {
      expr
      NULL
    }
  )
}

catch_cnd(log("x"))
#> <simpleError in log("x"): non-numeric argument to mathematical function>

catch_cnd(log(10))
#> NULL

We can also create a variant of the try function that returns a list consisting of a result and error component.

safety <- function(expr) {
  tryCatch(
    error = function(cnd) {
      list(result = NULL, error = cnd)
    },
    list(result = expr, error = NULL)
  )
}

safety(log("x"))
#> $result
#> NULL

#> $error
#> <simpleError in log("x"): non-numeric argument to mathematical function>

safety(log(10))
#> $result
#> [1] 2.302585

#> $error
#> NULL

Resignal

Handlers can be used to make informative error messages and simulate options(warn = 2) to turn a warning into an error for a single block of code to help with debugging. You could write a similar function if you were trying to locate the source of a message.

warning2error <- function(expr) {
  withCallingHandlers(
    warning = function(cnd) rlang::abort(conditionMessage(cnd)),
    expr
  )
}

rlang::warn("Hello")
#> Warning message:
#> Hello

warning2error({
  x <- 2 * 4
  rlang::warn("Hello")
})

#> Error:
#> ! Hello

Record

Recording conditions for later investigation can be accomplished with conditions if an object is modified in place as calling handlers are called for their side effects and cannot return values. You can also capture errors by wrapping withCallingHandlers in a tryCatch.

catch_cnds <- function(expr) {
  conds <- list()
  add_cond <- function(cnd) {
    conds <<- append(conds, list(cnd))
    rlang::cnd_muffle(cnd)
  }

  tryCatch(
    error = function(cnd) {
      conds <<- append(conds, list(cnd))
    },
    withCallingHandlers(
      message = add_cond,
      warning = add_cond,
      expr
    )
  )
  conds
}


catch_cnds({
  rlang::inform("a")
  rlang::warn("b")
  rlang::abort("c")
})

#> [[1]]
#> <message/rlang_message>
#> Message:
#> a

#> [[2]]
#> <warning/rlang_warning>
#> Warning:
#> b

#> [[3]]
#> <error/rlang_error>
#> Error:
#> ! c
#> ---
#> Backtrace:
#>     ▆
#>  1. ├─global catch_cnds(...)
#>  2. │ ├─base::tryCatch(...)
#>  3. │ │ └─base (local) tryCatchList(expr, classes, parentenv, handlers)
#>  4. │ │   └─base (local) tryCatchOne(expr, names, parentenv, handlers[[1L]])
#>  5. │ │     └─base (local) doTryCatch(return(expr), name, parentenv, handler)
#>  6. │ └─base::withCallingHandlers(...)
#>  7. └─rlang::abort("c")

The idea of recording conditions led to the creation of the evaluate R package which powers knitr. The functions in evaluate capture each output into a special data structure so it can be later replayed. The code is more complex as it needs to handle both plot and text output.

No Default Behavior

You can signal a condition that does not inherit from a message, warning, or error. In this case, there is no default behavior and the condition has no effect unless the user requests it. For example, you could create a logging system based on conditions:

log <- function(message, level = c("info", "error", "fatal")) {
  level <- match.arg(level)
  rlang::signal(message, "log", level = level)
}

log("This code was run")

The condition is signalled, but nothing happens with the code above as it has no default handler. To activate logging, you need a handler that does something with the log condition.

record_log <- function(expr, path = stdout()) {
  withCallingHandlers(
    log = function(cnd) {
      cat(
        "[",
        cnd$level,
        "] ",
        cnd$message,
        "\n",
        sep = "",
        file = path,
        append = TRUE
      )
    },
    expr
  )
}

record_log(log("Hello"))
#> [info] Hello

You could even add another function to selectively ignore some logging levels.

ignore_log_levels <- function(expr, levels) {
  withCallingHandlers(
    log = function(cnd) {
      if (cnd$level %in% levels) {
        rlang::cnd_muffle(cnd)
      }
    },
    expr
  )
}

record_log(ignore_log_levels(log("Hello"), "info"))