Defensive Programming and Input Validation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R (the base, utils and stats packages that ship with R) except one short section on the checkmate package, which was checked at version 2.3.4; the lintr 3.4.0 default linters were used only to check the scripts. A defensive function states what it expects, checks it where the data enters, and stops at once with a message that names the argument and the rule when the expectation is not met, instead of letting a wrong value travel through the program until some unrelated line fails, or worse, until it silently produces a wrong number. This page collects the base R tools for that job: stopifnot() and its named messages, the type and class predicates, match.arg() for a closed set of choices, the safe forms of looping and comparing (vapply(), seq_along(), identical(), all.equal(), isTRUE()), the options that report partial matching, classed custom conditions for errors that callers should be able to tell apart, and the .Deprecated() and .Defunct() pair for retiring an old function.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes (or where the linter cannot see a function that source("scenario.R") defines), so the linter does not hide the lesson. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. A call that is evaluated only for its effect, such as a validator that returns its argument invisibly, prints nothing, and the text says so. Almost every error on this page is caught with tryCatch(..., error = conditionMessage) so that the script keeps running and the message can be shown; how tryCatch() and the condition system work is the subject of Conditions and Error Handling, and finding the line that raised an error is covered on Debugging.

Fail fast, and where to check

The idea behind defensive programming is to move a failure as close as possible to its cause. A function that receives "300" where it wants a number can fail in three ways. It can fail late and far away: the first arithmetic on the string raises non-numeric argument to binary operator from inside some helper, and the message names an operator, not the argument that was wrong. It can fail silently: mean("300") returns NA with a warning that is easy to miss, and the NA then flows into a report. Or it can fail at once, at the top of the function, with a message that says which argument was wrong and what was expected. The first script shows the first two failures on the shelf scenario and the third as the repair.

source("scenario.R")

# Without a check the mistake surfaces far from its cause, or not at all.
due_date <- function(loan_date,
                     days = loan_days) { # nolint: object_usage_linter.
  loan_date + days
}
tryCatch(due_date("2026-06-01"), error = conditionMessage)
#> [1] "non-numeric argument to binary operator"

average_pages <- function(pages) {
  mean(pages)
}
tryCatch(average_pages("300"), warning = conditionMessage)
#> [1] "argument is not numeric or logical: returning NA"
suppressWarnings(average_pages("300"))
#> [1] NA
average_pages(books$pages)
#> [1] NA

# With a check at the top the message names the argument and the rule.
average_pages <- function(pages) {
  stopifnot(
    "pages must be a numeric vector" = is.numeric(pages),
    "pages must not be empty" = length(pages) > 0L
  )
  mean(pages, na.rm = TRUE)
}
tryCatch(average_pages("300"), error = conditionMessage)
#> [1] "pages must be a numeric vector"
average_pages(books$pages)
#> [1] 268.2143

average_pages("300") first returns NA with the warning argument is not numeric or logical: returning NA, which tryCatch(warning = ) turns into a value so the text can be shown; with the warning suppressed the caller sees only NA. The checked version also repairs a second, quieter problem: books$pages contains two NA values, and the checked function computes the mean of the known page counts on purpose (na.rm = TRUE) instead of returning NA by accident of the data. The point of a check is not only to reject bad input but to make the decisions about edge cases explicit.

Where to put the checks matters as much as how to write them. Three kinds of problem call for three different tools:

  • A programming error is a call that no correct program makes, such as passing a character vector where a function documents a number. stopifnot() and the type predicates are the tool: the message is for the developer, one cheap check at the top of the function is enough, and nobody is expected to recover from it.

  • A data error is a value that is wrong because the outside world is untidy, such as the ISBN with a wrong check digit in books.csv. The caller may well want to skip the row and go on, so the error should carry a class that a handler can select; that is the subject of the section on classed conditions below.

  • A recoverable state is something the program can handle by itself, such as a missing optional argument. It is not an error at all: a default, missing() or the %||% operator handles it.

The checks belong at the boundary of the system: the exported functions of a package, the constructor of a class, the function that reads a file or a request. Inside, the functions can trust what the boundary let through and do one cheap check or none. This is also how the book class of the shelf scenario is layered, which the second script shows.

source("scenario.R")

# Layer 1: the constructor checks types, programmer errors.
tryCatch(
  new_book(title = "Orchard Logic", year = 2019),
  error = conditionMessage
)
#> [1] "is.integer(year) is not TRUE"

# Layer 2: the validator checks values, user errors with plain messages.
tryCatch(book("   "), error = conditionMessage)
#> [1] "a book needs a title"
tryCatch(
  book("Orchard Logic", isbn = "9780306406158"),
  error = conditionMessage
)
#> [1] "invalid ISBN-13: 9780306406158"
tryCatch(book("Orchard Logic", year = 2200), error = conditionMessage)
#> [1] "implausible year: 2200"

# Layer 3: the user-facing helper cleans the input before it checks it.
book("  Orchard Logic ", "Iris Fenwick", "978-0-306-40615-7", 2019)
#> <book> Orchard Logic (Iris Fenwick, 2019)

# Inside the package a function trusts its input and does one cheap check.
page_count <- function(x) {
  stopifnot("x must be a book" = inherits(x, "book"))
  if (is.na(x$pages)) "unknown" else x$pages
}
page_count(book("Orchard Logic", pages = 310))
#> [1] 310
page_count(book("Orchard Logic"))
#> [1] "unknown"
tryCatch(page_count(list(title = "Orchard Logic")), error = conditionMessage)
#> [1] "x must be a book"

new_book() is the low-level constructor: it checks only the types, with an unnamed stopifnot(), and its message is the text of the failed expression. The pitfall in the first call is typical: 2019 is a double, and the constructor asks for an integer, so year = 2019L (or as.integer()) is needed; the user-facing book() helper does the conversion itself, which is why book("Orchard Logic", year = 2200) reaches the value check and not the type check. validate_book() checks values and signals plain errors with stop(..., call. = FALSE), which is a reasonable choice for messages that go to the end user, and the helper cleans the input (trimws(), normalise_isbn(), as.integer()) before the checks run. Inside, page_count() trusts a book and verifies only that it was given one.

stopifnot(): assertions with messages

stopifnot() is the most used validation tool in base R. Its help page says it is meant for regression tests and for checking the arguments of functions, where it makes the code easier to read (R documentation: stopifnot). It takes any number of expressions, evaluates them one after the other, and if one of them is not a logical vector of TRUE values it calls stop() with a message naming that expression. The help page gives the equivalent code: stopifnot(A, B) is conceptually if (any(is.na(A)) || !all(A)) stop(...) followed by the same for B. Two consequences follow. An NA is not good enough, because the point of an assertion is that the condition is known to hold. And a vector passes only when all of its elements are TRUE, so the idiom stopifnot(x > 0) checks a whole vector in one expression.

The default messages

The message is built from the failed expression: "<expression> is not TRUE" for a single value, and "<expression> are not all TRUE" when the value has a length other than one, a vector or NULL. When the expression is a call to all.equal(), the message reports the first difference found instead; that arrived in R 3.4.0 and was extended in R 4.3.0 to any call whose name partially matches all.equal, such as a user’s all.equalShow(), so that a customised comparison keeps working. The error is an ordinary simpleError whose call is the function that called stopifnot() (since R 3.6.0 stopifnot() no longer wraps every expression in its own error handling and may use sys.call() to report a short, meaningful call, which makes it considerably cheaper), so the console shows Error in check_count("a") : is.numeric(x) is not TRUE, a message that names the function the user called.

# The default message is the deparsed expression plus "is not TRUE".
check_count <- function(x) {
  stopifnot(is.numeric(x), length(x) == 1L)
  x
}
e <- tryCatch(check_count("a"), error = identity)
print(e)
#> <simpleError in check_count("a"): is.numeric(x) is not TRUE>
class(e)
#> [1] "simpleError" "error"       "condition"
conditionCall(e)
#> check_count("a")
conditionMessage(e)
#> [1] "is.numeric(x) is not TRUE"
tryCatch(check_count(1:2), error = conditionMessage)
#> [1] "length(x) == 1L is not TRUE"
check_count(5)
#> [1] 5

# A vector of logicals: "are not all TRUE" when more than one element.
positive <- function(x) stopifnot(x > 0)
tryCatch(positive(-1), error = conditionMessage)
#> [1] "x > 0 is not TRUE"
tryCatch(positive(c(1, -1, 2, -5)), error = conditionMessage)
#> [1] "x > 0 are not all TRUE"
tryCatch(positive(c(1, NA)), error = conditionMessage)
#> [1] "x > 0 are not all TRUE"
tryCatch(positive(NA), error = conditionMessage)
#> [1] "x > 0 is not TRUE"

# At the top level there is no calling function, so the call is empty.
e <- tryCatch(stopifnot("a" == "b"), error = identity)
print(e)
#> <simpleError: "a" == "b" is not TRUE>
conditionCall(e)
#> NULL

# Expressions are evaluated one by one; the first failure is reported.
e <- tryCatch(stopifnot(1 == 1, 2 < 1, 3 < 2), error = identity)
conditionMessage(e)
#> [1] "2 < 1 is not TRUE"

# A long expression is shortened in the message.
long_check <- function(x) {
  stopifnot(is.character(x) && length(x) == 1L && nzchar(x) && !is.na(x) &&
              x != "forbidden words here")
}
tryCatch(long_check(""), error = conditionMessage)
#> [1] "is.character(x) && length(x) == 1L && nzchar(x) && !is.na(x) &&  .... is not TRUE"

# all.equal() failures report the difference (R 3.4.0).
tryCatch(stopifnot(all.equal(pi, 3.14)), error = conditionMessage)
#> [1] "pi and 3.14 are not equal:\n  Mean relative difference: 0.0005069574"
stopifnot(all.equal(pi, 3.14159265))

Four details of the output are worth a pause. The condition has class c("simpleError", "error", "condition") and its call is check_count("a"), the call of the function that contains the assertion, not a call to stopifnot(); at the top level, with no enclosing function, the call is NULL and the console shows only Error:. The expressions are evaluated sequentially (since R 3.5.0), so stopifnot(is.numeric(x), x > 0) can safely put the type check first: the second expression is never evaluated when the first fails. A long expression is cut after a fixed length, and .... marks the cut. And the all.equal() form shows what differed (Mean relative difference: 0.0005069574) instead of just is not TRUE, which makes comparisons of computed numbers much easier to debug.

What the user sees

At the console, an uncaught error prints Error in <call> : <message>, or Error: <message> when there is no call. try() builds the same text as the value of a failed call, which makes it a convenient way to show the user’s view, and it keeps the condition object in the "condition" attribute for the programmer’s view. The named form of the message, introduced below, changes only the message, not the call.

check_count <- function(x) {
  stopifnot(is.numeric(x), length(x) == 1L)
  x
}

# try() keeps the error as a "try-error" and prints the text a user would see.
res <- try(check_count("a"), silent = TRUE)
class(res)
#> [1] "try-error"
cat(res)
#> Error in check_count("a") : is.numeric(x) is not TRUE
conditionMessage(attr(res, "condition"))
#> [1] "is.numeric(x) is not TRUE"
inherits(res, "try-error")
#> [1] TRUE

# The same call with the named form of the message.
check_count_named <- function(x) {
  stopifnot("x must be one number" = is.numeric(x) && length(x) == 1L)
  x
}
res <- try(check_count_named("a"), silent = TRUE)
cat(res)
#> Error in check_count_named("a") : x must be one number

# With no calling function the message starts with "Error:" only.
res <- try(stopifnot("pages must be positive" = -1 > 0), silent = TRUE)
cat(res)
#> Error : pages must be positive

The last call has no enclosing function, so try() builds the text without a call: Error : followed by the message, with a space before the colon.

Pitfalls of the default behaviour

The rules of the previous section have edge cases that surprise people who think of stopifnot() as "an if that stops". The help page warns about the first: positive numbers are not TRUE, even though they are coerced to TRUE inside if(). So stopifnot(length(x)) fails for length(x) == 1, although if (length(x)) would take the branch. The script collects the others.

err <- function(expr) {
  tryCatch({
    expr
    "no error"
  }, error = conditionMessage)
}

# Positive numbers are not TRUE, even though if (1) works.
err(stopifnot(1))
#> [1] "1 is not TRUE"
err(stopifnot(length(10)))
#> [1] "length(10) is not TRUE"
if (1) "if accepts a number"
#> [1] "if accepts a number"

# NA is not TRUE.
err(stopifnot(NA))
#> [1] "NA is not TRUE"
err(stopifnot(c(TRUE, NA, TRUE)))
#> [1] "c(TRUE, NA, TRUE) are not all TRUE"

# A zero-length condition passes: nothing is "not TRUE".
check_all <- function(x) stopifnot(x)
err(check_all(logical()))
#> [1] "no error"
err(check_all(NULL))
#> [1] "x are not all TRUE"
err(check_all(c(a = TRUE, b = FALSE)))
#> [1] "x are not all TRUE"
err(check_all("TRUE"))
#> [1] "x is not TRUE"

# Comparing a string with a number coerces the number to a string.
positive <- function(x) stopifnot(x > 0)
err(positive("a"))
#> [1] "no error"
"a" > 0
#> [1] TRUE
"10" > 9
#> [1] FALSE

# Guard the type first, in the same call.
positive_number <- function(x) {
  stopifnot(is.numeric(x), x > 0)
  x
}
err(positive_number("a"))
#> [1] "is.numeric(x) is not TRUE"
err(positive_number(-3))
#> [1] "x > 0 is not TRUE"
err(positive_number(numeric()))
#> [1] "no error"

The surprising results, in order. A condition that evaluates to NA fails, with NA is not TRUE. A logical vector of length zero passes, because "all of nothing" is TRUE: check_all(logical()) is silent, which matters for code that checks x > 0 on a vector that may be empty, so a check that must reject empty input needs its own length(x) > 0L expression. NULL and a character string are not logical vectors, so they fail even though "TRUE" looks right. And a comparison such as x > 0 is not a type check at all: when x is the string "a", R converts 0 to "0" and compares strings with the collation order of the locale, which gives TRUE for "a" > 0, and "10" > 9 is FALSE because "10" sorts before "9". That is why positive_number() puts is.numeric(x) before x > 0 in the same stopifnot() call, where the sequential evaluation makes the order safe. Note also that positive_number(numeric()) passes: an empty vector satisfies both expressions, so add a length condition when a single value is required, as in the next section.

Named messages (R 4.0.0)

Before R 4.0.0 the message of stopifnot() was always generated from the expression, and a message written for the user needed an if and a stop(). The R 4.0.0 NEWS lists this as a change to stopifnot(): error messages can be customised through the names of the arguments (R NEWS: Changes in R 4.0.0). The name of an argument replaces the generated text, and an argument without a name keeps the default. Because the names are character strings, they are written with quotes: "x must be a number" = is.numeric(x). The help page demonstrates the form with a symmetric-matrix check whose name replaces the default message.

A good message states the rule from the caller’s point of view, in the present tense, and names the argument: "pages must be positive" says what is required, where all(pages > 0) is not TRUE says only what the code computed. When several named conditions are given, they are evaluated in order and the name of the first one that fails is reported, so they can be arranged from the cheapest and most basic check to the most specific. Inside one condition, && evaluates from left to right and stops at the first FALSE, so is.numeric(x) && length(x) == 1L && x > 0 never reaches the comparison for a string or a vector.

source("scenario.R")

# A name on an argument replaces the default message (R 4.0.0).
positive <- function(x) {
  stopifnot("x must be a single positive number" = is.numeric(x) &&
              length(x) == 1L && x > 0)
  x
}
e <- tryCatch(positive("a"), error = identity)
print(e)
#> <simpleError in positive("a"): x must be a single positive number>
conditionMessage(e)
#> [1] "x must be a single positive number"
conditionCall(e)
#> positive("a")

# Several named conditions: the name of the first one that fails is reported.
check_pages <- function(pages) {
  stopifnot(
    "pages must be numeric" = is.numeric(pages),
    "pages must be whole numbers" = all(pages == round(pages), na.rm = TRUE),
    "pages must be positive" = all(pages > 0, na.rm = TRUE)
  )
  invisible(pages)
}
tryCatch(check_pages("300"), error = conditionMessage)
#> [1] "pages must be numeric"
tryCatch(check_pages(c(310, 288.5)), error = conditionMessage)
#> [1] "pages must be whole numbers"
tryCatch(check_pages(c(310, -4)), error = conditionMessage)
#> [1] "pages must be positive"
check_pages(books$pages)

# The validator for an ISBN column of the shelf scenario.
check_isbn <- function(x) {
  stopifnot(
    "isbn must be a character vector" = is.character(x),
    "isbn must not contain missing values" = !anyNA(x),
    "isbn must have 13 digits" =
      all(nchar(normalise_isbn(x)) == 13L), # nolint: object_usage_linter.
    "isbn has a wrong check digit" =
      all(is_valid_isbn13(x)) # nolint: object_usage_linter.
  )
  invisible(x)
}
tryCatch(check_isbn(books$isbn), error = conditionMessage)
#> [1] "isbn must have 13 digits"
tryCatch(check_isbn(books$isbn[c(1, 13)]), error = conditionMessage)
#> [1] "isbn has a wrong check digit"
tryCatch(check_isbn(c(books$isbn[1], NA)), error = conditionMessage)
#> [1] "isbn must not contain missing values"
tryCatch(check_isbn(9780306406157), error = conditionMessage)
#> [1] "isbn must be a character vector"
check_isbn(books$isbn[1:3])
identical(check_isbn(books$isbn[1:3]), books$isbn[1:3])
#> [1] TRUE

# The scenario's own constructor checks types with unnamed conditions.
tryCatch(new_book(title = 1), error = conditionMessage)
#> [1] "is.character(title) is not TRUE"
tryCatch(new_book(year = 2019), error = conditionMessage)
#> [1] "is.integer(year) is not TRUE"

The check_isbn() validator is the kind of function that fits the form: four named conditions, each with a message the user of the function can act on. It returns its argument invisibly, so a call such as check_isbn(books$isbn[1:3]) prints nothing and the function can be used inside a pipeline; the identical() line confirms that the value is returned unchanged. Applied to the whole isbn column it reports isbn must have 13 digits, because the column contains the empty string of row 9; applied to rows 1 and 13 it reports the wrong check digit of "Orchard Logic". The call recorded in the condition is again the function that contains the assertion, check_isbn(...), so the console shows Error in check_isbn(books$isbn) : isbn must have 13 digits. The last two lines show that the scenario’s own constructor uses unnamed conditions: its messages are the text of the failed expression, which is fine for a developer and less friendly for a user.

exprs, exprObject and local

Three more arguments of stopifnot() are about how the expressions are supplied. Since R 3.5.0 the expressions can be given as a block, stopifnot(exprs = { ... }), one expression per line without separating commas, which the help page describes as more convenient to evaluate interactively, line by line. R 4.0.0 added exprObject, which takes an object that is already a language object: an expression() vector, a call, a name, or a constant such as TRUE. The argument local says in which environment the block is evaluated and defaults to the calling frame; the help page documents it for exprs only.

source("scenario.R")

# exprs = { } takes several expressions without separating commas (R 3.5.0).
check_loan <- function(loan_date, return_date) {
  stopifnot(exprs = {
    inherits(loan_date, "Date")
    inherits(return_date, "Date")
    length(loan_date) == length(return_date)
    all(return_date >= loan_date, na.rm = TRUE)
  })
  invisible(TRUE)
}
tryCatch(check_loan("2026-06-01", shelf_today), error = conditionMessage)
#> [1] "inherits(loan_date, \"Date\") is not TRUE"
tryCatch(check_loan(shelf_today, shelf_today - 3), error = conditionMessage)
#> [1] "all(return_date >= loan_date, na.rm = TRUE) is not TRUE"
check_loan(loans$loan_date[1:5], loans$return_date[1:5])
tryCatch(
  check_loan(loans$loan_date, loans$return_date),
  error = conditionMessage
)
#> [1] "all(return_date >= loan_date, na.rm = TRUE) is not TRUE"
sum(loans$return_date < loans$loan_date, na.rm = TRUE)
#> [1] 1

# Names inside exprs = { } are not used as messages: the expression is shown.
tryCatch(
  stopifnot(exprs = {
    "pages must be positive" = -1 > 0 # nolint: object_name_linter.
  }),
  error = conditionMessage
)
#> [1] "\"pages must be positive\" = -1 > 0 is not TRUE"

# exprObject takes an expression vector, a call, a name or a constant (4.0.0).
rules <- expression(
  is.character(books$title),
  nrow(books) > 100
)
tryCatch(stopifnot(exprObject = rules), error = conditionMessage)
#> [1] "nrow(books) > 100 is not TRUE"
stopifnot(exprObject = quote(nrow(books) == 16L))
stopifnot(exprObject = TRUE)

# local chooses the environment in which exprs = { } is evaluated.
scope <- list2env(list(limit = 3, pages = 300))
stopifnot(exprs = {
  limit > 2
  pages > 100
}, local = scope)
tryCatch(
  stopifnot(exprs = {
    limit > 5
  }, local = scope),
  error = conditionMessage
)
#> [1] "limit > 5 is not TRUE"

# A shortcut named assert(), following the help page of stopifnot().
assert <- function(exprs) eval.parent(substitute(stopifnot(exprs = exprs)))
tryCatch(assert({
  nrow(books) == 16L
  ncol(books) == 7L
}), error = conditionMessage)
#> [1] "ncol(books) == 7L is not TRUE"

Two details are worth noting. First, the names in an exprs = { ... } block are not used as messages: the generated message shows the expression with its name, "pages must be positive" = -1 > 0 is not TRUE, so the block form is for the default messages and the named form needs the ordinary argument syntax. Second, a shortcut for the block form needs care. The help page warns that assertWRONG <- function(exprs) stopifnot(exprs = exprs) is not a good idea, because the argument would be evaluated before stopifnot() sees it, and gives the safe version that the last block reproduces: assert <- function(exprs) eval.parent(substitute(stopifnot(exprs = exprs))). It captures the unevaluated block with substitute() and evaluates stopifnot() in the caller’s frame; the capturing is the subject of Language Objects and Quoting.

stopifnot() is not the right tool when the caller must be able to tell the failure from others, since every failure is a plain simpleError: the section on classed conditions below shows what to use then. It is also not a replacement for warning(): the help page shows how to build a warnifnot() from it, but the construction manipulates the function body and is a curiosity rather than a recommendation.

Type and shape checks

A check has two halves: the type of the value (is it a character vector, a function, a data frame?) and its shape (how long, how many missing values, within what range?). Most mistakes in real programs come from forgetting the second half, so the examples below combine them.

The is.* predicates

The predicates ask about the storage type or the interpretation of a value, and a few of them are subtler than their names. is.numeric() is the one most often misunderstood: it is not the same as is.double(). It is TRUE for integer and double vectors and FALSE for factors, which are stored as integers, and for objects with a class that opts out: the help page lists methods for "Date", "POSIXt", "difftime" and "complex" that return FALSE (R documentation: numeric). The intent is that is.numeric(x) means "arithmetic on x makes sense and the values are numbers", which is what a function that computes a mean wants to know. is.integer() and is.double() ask about the storage type instead, so is.integer(5) is FALSE. is.character() is FALSE for factors and for a bare NA, which is logical, while NA_character_ is character. is.function() accepts closures and primitives but not the string with a function’s name. is.null() is TRUE only for NULL itself, not for an empty list or an empty vector.

source("scenario.R")

# is.numeric() is TRUE for integer and double, FALSE for factors and dates.
is.numeric(1L)
#> [1] TRUE
is.numeric(1.5)
#> [1] TRUE
is.numeric(factor("Mystery"))
#> [1] FALSE
is.numeric(shelf_today)
#> [1] FALSE
is.numeric(as.difftime(21, units = "days"))
#> [1] FALSE
is.numeric(NA)
#> [1] FALSE
is.numeric("1")
#> [1] FALSE

# is.integer() and is.double() ask for the storage type instead.
is.integer(5)
#> [1] FALSE
is.integer(5L)
#> [1] TRUE
is.double(5L)
#> [1] FALSE
typeof(books$year)
#> [1] "integer"
typeof(books$pages)
#> [1] "integer"

# is.character() is FALSE for factors; NA alone is logical.
is.character(NA)
#> [1] FALSE
is.character(NA_character_)
#> [1] TRUE
is.character(factor("Mystery"))
#> [1] FALSE
is.character(books$genre)
#> [1] TRUE

# is.function() accepts closures and primitives, not names of functions.
is.function(mean)
#> [1] TRUE
is.function(sum)
#> [1] TRUE
is.function("mean")
#> [1] FALSE
is.function(\(x) x + 1)
#> [1] TRUE

# is.logical(), is.list() and is.null().
is.logical(NA)
#> [1] TRUE
is.list(books)
#> [1] TRUE
is.list(unclass(book("Orchard Logic")))
#> [1] TRUE
is.null(NULL)
#> [1] TRUE
is.null(list())
#> [1] FALSE
is.null(character())
#> [1] FALSE

# is.vector() is FALSE when an attribute other than names is present.
is.vector(1:3)
#> [1] TRUE
is.vector(c(a = 1))
#> [1] TRUE
is.vector(factor("Mystery"))
#> [1] FALSE
is.vector(structure(1, units = "pages"))
#> [1] FALSE
is.atomic(NULL)
#> [1] FALSE

is.vector() is the predicate with the most traps. It is TRUE for a plain vector, possibly with names, and FALSE as soon as any other attribute is present, so a vector with a units attribute and a factor both fail; if the question is "can I treat this as a sequence of values?", is.atomic() or a check with is.numeric() and is.character() is closer. is.atomic(NULL) is FALSE since R 4.4.0 (it was TRUE before), which is one of the changes the older tutorials do not know about. The other predicates (is.logical(), is.list(), is.data.frame(), is.environment() and so on) follow the same pattern: they test the basic type, not the S3 class.

Question Test Remarks

Is it a number I can compute with?

is.numeric(x)

TRUE for integer and double; FALSE for factors, Date, POSIXt, difftime.

Is it stored as an integer?

is.integer(x)

FALSE for 5; the literal 5L is an integer. For "whole number" use x == round(x).

Is it text?

is.character(x)

FALSE for factors and for a bare NA.

Is it TRUE or FALSE, nothing else?

isTRUE(x), isFALSE(x)

One logical value, not NA; see the section on comparing.

Is it a function?

is.function(x)

Closures and primitives; not a string.

Is it a plain vector?

is.vector(x)

FALSE when an attribute other than names is present.

Is it a list or a data frame?

is.list(x), is.data.frame(x)

A data frame is also a list.

Is it of a given S3 class?

inherits(x, "class")

Looks at the class vector, not the storage type.

Is it missing or NULL?

is.na(x), anyNA(x), is.null(x)

is.na(NULL) is logical(0).

Classes: inherits() and isa()

For a value with an S3 class, the right question is not the storage type but the class vector. inherits(x, what) is TRUE when any of the classes in what appears in class(x); which = TRUE returns the position of each match instead (zero for no match), which tells how far down the class vector each match is (R documentation: class). isa(x, what) is stricter: it is TRUE only when all the classes in what are in class(x), and for an S4 object it uses is(). Both look at class(x), which is defined for every object: inherits(1L, "integer") is TRUE, inherits(1L, "numeric") is FALSE (the class of an integer vector is "integer"; "numeric" is the class of a double), and inherits(matrix(1:4, 2), "matrix") is TRUE.

The one comparison to avoid is class(x) == "name". Since R 4.0.0 a matrix has the class vector c("matrix", "array"), so class(m) == "matrix" has length two, if (class(m) == "matrix") has been an error since R 4.2.0, and older code that tested class(x) == "matrix" broke when matrices gained their second class. inherits() is robust to a longer class vector and to subclasses, which the book scenario depends on: a validator written with inherits(x, "book") still accepts an object of a class derived from book.

source("scenario.R")
b <- book("Orchard Logic", "Iris Fenwick", year = 2019L)

# inherits() looks at class(x), so it asks about the S3 class vector.
class(b)
#> [1] "book"
inherits(b, "book")
#> [1] TRUE
inherits(b, "list")
#> [1] FALSE
is.list(b)
#> [1] TRUE
inherits(books, "data.frame")
#> [1] TRUE
inherits(matrix(1:4, 2), "matrix")
#> [1] TRUE
inherits(1L, "integer")
#> [1] TRUE
inherits(1L, "numeric")
#> [1] FALSE
inherits(1, "numeric")
#> [1] TRUE

# Comparing class(x) with == breaks when the class has several elements:
# a matrix has been a "matrix" and an "array" since R 4.0.0.
m <- matrix(1:4, 2)
class(m)
#> [1] "matrix" "array"
class(m) == "matrix"
#> [1]  TRUE FALSE
tryCatch(
  if (class(m) == "matrix") "a matrix",
  error = conditionMessage
)
#> [1] "the condition has length > 1"
inherits(m, "matrix")
#> [1] TRUE

# what can have several classes; which = TRUE gives the position of each.
inherits(books, c("tbl", "data.frame"))
#> [1] TRUE
inherits(books, c("tbl", "data.frame"), which = TRUE)
#> [1] 0 1
inherits(b, c("book", "list"), which = TRUE)
#> [1] 1 0

# isa() requires all the classes at once.
isa(b, "book")
#> [1] TRUE
isa(books, c("tbl", "data.frame"))
#> [1] FALSE
isa(books, "data.frame")
#> [1] TRUE

# A validator that accepts the shelf's own class or a plain list of fields.
as_book <- function(x) {
  if (inherits(x, "book")) {
    return(x)
  }
  stopifnot("x must be a book or a list of book fields" = is.list(x))
  do.call(book, x) # nolint: object_usage_linter.
}
as_book(b)
#> <book> Orchard Logic (Iris Fenwick, 2019)
as_book(list(title = "Orchard Logic", year = 2019L))
#> <book> Orchard Logic (NA, 2019)
tryCatch(as_book("Orchard Logic"), error = conditionMessage)
#> [1] "x must be a book or a list of book fields"

The as_book() function at the end of the script shows the usual shape of a flexible validator: accept the target class unchanged, convert what can be converted, and stop with a message for the rest. The conversion branch uses do.call(book, x), so the list of fields goes through the same validate_book() checks as any other input; the S3 Classes and Generics page builds the class itself.

One value, not a vector

Most R values are vectors, and a vector is the wrong shape for if(), && and ||, which need exactly one logical value. The old behaviour was lenient: if (c(TRUE, FALSE)) used the first element and warned, and && silently used the first element of each operand. The language has become stricter in two steps. R 4.2.0 made if() and while() with a condition of length greater than one an error, and && and || with such an operand a warning; R 4.3.0 made the && and || case an error too, with a message of the form 'length = 2' in coercion to 'logical(1)' (R NEWS: Changes in R 4.3.0). A validator that begins with the type and the length therefore never reaches a vector in a position that needs a scalar.

# The usual "one valid string" test: type, length and missingness together.
is_scalar_string <- function(x) {
  is.character(x) && length(x) == 1L && !is.na(x) && nzchar(x)
}
is_scalar_string("Orchard Logic")
#> [1] TRUE
is_scalar_string("")
#> [1] FALSE
is_scalar_string(NA_character_)
#> [1] FALSE
is_scalar_string(c("a", "b"))
#> [1] FALSE
is_scalar_string(1)
#> [1] FALSE

# && and || need length-one operands; a longer one is an error since R 4.3.0.
x <- c(310, 288)
tryCatch(x > 0 && TRUE, error = conditionMessage)
#> [1] "'length = 2' in coercion to 'logical(1)'"
tryCatch(is.numeric(x) && x > 0, error = conditionMessage)
#> [1] "'length = 2' in coercion to 'logical(1)'"
tryCatch(if (x > 0) "positive", error = conditionMessage)
#> [1] "the condition has length > 1"

# if() on NA, NULL and non-logical text has its own messages.
tryCatch(if (NA) 1, error = conditionMessage)
#> [1] "missing value where TRUE/FALSE needed"
tryCatch(if (NULL) 1, error = conditionMessage)
#> [1] "argument is of length zero"
tryCatch(if (logical()) 1, error = conditionMessage)
#> [1] "argument is of length zero"
tryCatch(if ("yes") 1, error = conditionMessage)
#> [1] "argument is not interpretable as logical"
tryCatch(if ("TRUE") "text TRUE is accepted", error = conditionMessage)
#> [1] "text TRUE is accepted"

# any() and all() turn a vector into the single value that if() needs.
if (all(x > 0)) "all positive"
#> [1] "all positive"
if (any(x > 300)) "some above 300"
#> [1] "some above 300"
all(logical())
#> [1] TRUE
any(logical())
#> [1] FALSE

# A validator for a count: type, length, missing, whole number and range.
is_count <- function(x, max = Inf) {
  is.numeric(x) && length(x) == 1L && !is.na(x) &&
    x == round(x) && x >= 0 && x <= max
}
is_count(3)
#> [1] TRUE
is_count(3.5)
#> [1] FALSE
is_count(-1)
#> [1] FALSE
is_count(NA_integer_)
#> [1] FALSE
is_count(500, max = 100)
#> [1] FALSE

The script builds two scalar validators from the same four tests (type, length, no NA, then the value rule), the same shape as the conditions of the named stopifnot() calls. is_scalar_string() also uses nzchar() to reject the empty string, which is.character() accepts. In is_count(), the order of the operands matters: && stops at the first FALSE, so x == round(x) is evaluated only for a number of length one that is not NA. The error messages of if() are worth knowing by heart because they point at a missing check: missing value where TRUE/FALSE needed means the condition was NA, argument is of length zero that it was empty or NULL, the condition has length > 1 that a vector reached if(), and argument is not interpretable as logical that the condition was text that R cannot read as a logical value. any() and all() are the bridge from a vector to the single value that if() needs, with the two empty-input conventions that every R programmer should remember: all(logical()) is TRUE and any(logical()) is FALSE.

Closed sets of choices: match.arg()

An argument that must be one of a few strings is the classic case for match.arg(). The function declares the set as the default value, status = c("all", "out", "overdue", "returned"), and match.arg(status) replaces the vector by the validated choice: the first element when the caller passed nothing, the full name when the caller passed an abbreviation, and an error for anything else. In the one-argument form the choices are read from the default of the formal argument of the calling function, so the set is written once, in the signature, where args() and the help page show it. Matching uses pmatch(), so an abbreviation is accepted when it is unambiguous, and the empty string never matches (R documentation: match.arg). The mechanics and the interaction with the dots are described on Functions; this page looks at it as a validator.

source("scenario.R")

# The default lists the choices; match.arg() validates what the caller passed.
loan_report <- function(loans,
                        status = c("all", "out", "overdue", "returned")) {
  status <- match.arg(status)
  end <- loans$return_date
  end[is.na(end)] <- shelf_today # nolint: object_usage_linter.
  days <- as.numeric(end - loans$loan_date)
  switch(status,
    all = nrow(loans),
    out = sum(is.na(loans$return_date)),
    overdue = sum(days > loan_days, # nolint: object_usage_linter.
                  na.rm = TRUE),
    returned = sum(!is.na(loans$return_date))
  )
}
loan_report(loans)
#> [1] 60
loan_report(loans, "out")
#> [1] 7
loan_report(loans, "ov")
#> [1] 32
loan_report(loans, "ret")
#> [1] 53

# The error says what is allowed. Its call is match.arg(status), not the
# call of loan_report().
e <- tryCatch(loan_report(loans, "lost"), error = identity)
print(e)
#> <simpleError in match.arg(status): 'arg' should be one of “all”, “out”, “overdue”, “returned”>
conditionCall(e)
#> match.arg(status)

# Other wrong values have their own messages.
tryCatch(loan_report(loans, 1), error = conditionMessage)
#> [1] "'arg' must be NULL or a character vector"
tryCatch(loan_report(loans, NA_character_), error = conditionMessage)
#> [1] "'arg' should be one of “all”, “out”, “overdue”, “returned”"
tryCatch(loan_report(loans, character()), error = conditionMessage)
#> [1] "'arg' must be of length 1"
tryCatch(loan_report(loans, c("out", "overdue")), error = conditionMessage)
#> [1] "'arg' must be of length 1"
tryCatch(loan_report(loans, "o"), error = conditionMessage)
#> [1] "'arg' should be one of “all”, “out”, “overdue”, “returned”"

# NULL means "take the first choice"; the empty string never matches.
loan_report(loans, NULL)
#> [1] 60
tryCatch(match.arg("", c("", "a", "B")), error = conditionMessage)
#> [1] "'arg' should be one of “a”, “B”"

# The two-argument form works outside a function.
match.arg("gen", c("genre", "title", "year"))
#> [1] "genre"
tryCatch(match.arg("ye", c("year", "years")), error = conditionMessage)
#> [1] "'arg' should be one of “year”, “years”"

The error message lists the allowed values, which is what the user needs, but three of its properties are worth knowing. Its call is match.arg(status), not the call of loan_report(), so the console shows Error in match.arg(status) : 'arg' should be one of ...; the user sees the name of the argument but not the function they called, and the traceback described on Debugging is the way to find the caller when many functions share an argument name. The wording depends on the input: a number gives 'arg' must be NULL or a character vector, a vector of length two or a zero-length vector gives 'arg' must be of length 1, and anything else that does not match gives the should be one of message. And the help page warns that the wording of these messages is liable to change, and that it did change in R 4.2.0, so a package must not test for them. A test should check that an error is signalled, or catch it and check only the part that matters. When the call or the wording is important, validate by hand with %in% and signal a classed condition, as shown below.

NULL is accepted and means "take the first choice", which matters for a caller that builds the argument programmatically and passes NULL for "use the default". The R 4.2.0 NEWS records a better error message for match.arg("", ...).

Several values at once

With several.ok = TRUE the argument may have more than one element, and every element that matches is returned; the elements that do not match are dropped without a word, so a typo goes unnoticed and the caller gets fewer columns than they asked for. R 4.6.0 added a third value for several.ok: any string starting with "all" keeps the several-values behaviour but requires every element to match. The R 4.6.0 NEWS entry says that match.arg() gets this option, fulfilling a wish recorded as PR#16659 (R NEWS: Changes in R 4.6.0). It replaces the check that people wrote by hand after several.ok = TRUE, shown in the second function of the script.

# several.ok = "all" (R 4.6.0) accepts several values and checks every one.
shelf_columns <- c("title", "author", "isbn", "year", "genre", "pages")
pick_columns <- function(cols = shelf_columns) {
  match.arg(cols, shelf_columns, several.ok = "all")
}
pick_columns()
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"
pick_columns(c("ti", "pa"))
#> [1] "title" "pages"
tryCatch(pick_columns(c("ti", "publisher")), error = conditionMessage)
#> [1] "'arg' should be one of “title”, “author”, “isbn”, “year”, “genre”, “pages”"

# several.ok = TRUE drops what does not match, silently.
match.arg(c("ti", "publisher"), shelf_columns, several.ok = TRUE)
#> [1] "title"
tryCatch(
  match.arg(c("ti", "publisher"), shelf_columns, several.ok = TRUE),
  error = conditionMessage
)
#> [1] "title"
tryCatch(
  match.arg("publisher", shelf_columns, several.ok = TRUE),
  error = conditionMessage
)
#> [1] "'arg' should be one of “title”, “author”, “isbn”, “year”, “genre”, “pages”"

# Before 4.6.0 the portable check compared the lengths by hand.
pick_columns_old <- function(cols = shelf_columns) {
  matched <- match.arg(cols, shelf_columns, several.ok = TRUE)
  stopifnot("every column must be a known column" = length(matched) ==
              length(cols))
  matched
}
pick_columns_old(c("ti", "pa"))
#> [1] "title" "pages"
tryCatch(pick_columns_old(c("ti", "publisher")), error = conditionMessage)
#> [1] "every column must be a known column"

The set of allowed columns is a single character vector that serves as both the default and the table, so the signature, the check and the error message cannot drift apart. Code that has to run on R 4.5 or older cannot use "all"; there the portable form is the second function, which compares the length of the matched vector with the length of the input.

Missing arguments, NULL defaults and %||%

An argument without a default that the caller did not supply is an error only when the function first evaluates it, which can be far from the top of the function, so the message argument "x" is missing, with no default appears at the use, not at the call. The error names the call (here double_it()) but not the position in the body. The error has the class missingArgError: the R 4.5.0 NEWS mentions that class, with a subclass "getMissingError" for get(), and uses f <- function(x) exp(x); try(f()) as the example of the situation. A handler can therefore select on the class instead of on the message (R NEWS: Changes in R 4.5.0). The class vector has further subclasses whose names depend on how the error was signalled, so test for "missingArgError" with inherits() and do not print the whole vector.

missing(x) is TRUE when the caller did not supply x, and is the way to turn the late, obscure error into an early one with a clear message. A stricter alternative to the default NULL is not to give a default at all and to check with missing() at the top, as borrow() does with named conditions. nargs() counts the arguments that were supplied, including empty ones, which is useful for functions that accept one of several argument combinations. The mechanics of missing() (for example that it follows an argument forwarded through another function, and that it becomes FALSE once the argument is assigned to) are described on Functions, and the deferred evaluation that explains the late error is the subject of Lazy Evaluation and Promises.

source("scenario.R")

# A missing argument without a default is an error of its own class.
double_it <- function(x) x * 2
e <- tryCatch(double_it(), error = identity)
conditionMessage(e)
#> [1] "argument \"x\" is missing, with no default"
conditionCall(e)
#> double_it()
inherits(e, "missingArgError")
#> [1] TRUE

# missing() asks whether the caller supplied the argument.
describe <- function(title, author) {
  if (missing(author)) {
    return(paste(title, "(author unknown)"))
  }
  paste0(title, " by ", author)
}
describe("Orchard Logic")
#> [1] "Orchard Logic (author unknown)"
describe("Orchard Logic", "Iris Fenwick")
#> [1] "Orchard Logic by Iris Fenwick"

# Checking a required argument before use gives a better message.
borrow <- function(isbn, member) {
  stopifnot(
    "isbn is required" = !missing(isbn),
    "member is required" = !missing(member)
  )
  paste(member, "borrows", isbn)
}
tryCatch(borrow(member = "Ana Ruiz"), error = conditionMessage)
#> [1] "isbn is required"
tryCatch(borrow("9780306406157"), error = conditionMessage)
#> [1] "member is required"
borrow("9780306406157", "Ana Ruiz")
#> [1] "Ana Ruiz borrows 9780306406157"

# nargs() counts the arguments supplied, empty ones included.
count_args <- function(a, b, ...) nargs()
count_args()
#> [1] 0
count_args(1)
#> [1] 1
count_args(1, 2, 3, 4)
#> [1] 4
count_args(, 2)
#> [1] 2

# A NULL default tested with is.null(), or the %||% operator (R 4.4.0).
label <- function(title, author = NULL) {
  author <- author %||% "unknown author"
  paste0(title, " (", author, ")")
}
label("Orchard Logic")
#> [1] "Orchard Logic (unknown author)"
label("Orchard Logic", "Iris Fenwick")
#> [1] "Orchard Logic (Iris Fenwick)"
NULL %||% "fallback"
#> [1] "fallback"
"value" %||% "fallback"
#> [1] "value"
character() %||% "fallback"
#> character(0)
NA %||% "fallback"
#> [1] NA

For an optional argument the usual choice is a default of NULL and a replacement inside the function, and R 4.4.0 added an operator for the replacement: L %||% R returns L unless L is NULL, in which case it returns R. The NEWS entry describes it as an expressive form of the two phrases if (!is.null(L)) L else R and if (is.null(L)) R else L (R NEWS: Changes in R 4.4.0); it is documented with the control-flow constructs (R documentation: Control). Only NULL triggers the replacement: character(), NA and the empty string are values and are passed through, which is why character() %||% "fallback" is character(0). For code that must run on R 4.3 or older, the operator can be defined in a few lines, and several packages define it for that reason.

Looping and mapping safely

Loops and the apply family are where defensive habits pay off, because the input that breaks them is usually the empty one, and the empty case rarely appears in the data used while writing the code.

vapply() instead of sapply()

sapply() applies a function and then simplifies the result by guessing: a vector if every result has length one, a matrix if they all have the same longer length, otherwise a list. That guess is made from the data, so the same code returns different types for different inputs. On empty input it has nothing to guess from and returns an empty list(), so code that expects a vector fails or, worse, keeps going with a list. vapply() takes the expected shape as an argument, FUN.VALUE, and checks every result against it: the result always has the declared type, empty input gives an empty vector of that type, and a result of the wrong length or type is an error that names the element. The help page states the rule: the values of FUN must have the same length and type as FUN.VALUE, where "type" may be promoted along logical, integer, double and complex but not demoted (R documentation: lapply).

source("scenario.R")

# sapply() guesses the shape of its result; on empty input it guesses a list.
sapply(c("a", "bb"), nchar)
#>  a bb
#>  1  2
sapply(character(), nchar)
#> named list()
class(sapply(character(), nchar))
#> [1] "list"
sapply(list(), length)
#> list()
vapply(character(), nchar, integer(1))
#> named integer(0)
vapply(list(), length, integer(1))
#> integer(0)

# The shape changes with the data: a matrix, a list, or a vector.
sapply(c("ab", "cd"), \(s) strsplit(s, "")[[1L]])
#>      ab  cd
#> [1,] "a" "c"
#> [2,] "b" "d"
sapply(c("ab", "c"), \(s) strsplit(s, "")[[1L]])
#> $ab
#> [1] "a" "b"
#>
#> $c
#> [1] "c"
#>

# vapply() states the shape up front and stops when a result does not fit.
digits <- vapply(books$isbn[1:3], \(s) nchar(normalise_isbn(s)), integer(1))
digits
#> 978-0-306-40615-7 978-0-14-143951-8     9780001000100
#>                13                13                13
vapply(books$isbn[1:3], \(s) nchar(normalise_isbn(s)), integer(1),
       USE.NAMES = FALSE)
#> [1] 13 13 13
tryCatch(
  vapply(c("ab", "c"), \(s) strsplit(s, "")[[1L]], character(1)),
  error = conditionMessage
)
#> [1] "values must be length 1,\n but FUN(X[[1]]) result is length 2"
tryCatch(
  vapply(c("ab", "c"), nchar, character(1)),
  error = conditionMessage
)
#> [1] "values must be type 'character',\n but FUN(X[[1]]) result is type 'integer'"
tryCatch(
  vapply(1:2, \(i) i + 0.5, integer(1)),
  error = conditionMessage
)
#> [1] "values must be type 'integer',\n but FUN(X[[1]]) result is type 'double'"

# Promotion is allowed: logical to integer to double, never the other way.
vapply(c(TRUE, FALSE), \(i) i, integer(1))
#> [1] 1 0
vapply(1:2, \(i) i, numeric(1))
#> [1] 1 2

# A named FUN.VALUE with length > 1 gives a matrix with those row names.
span <- vapply(
  split(books$year, books$genre),
  \(y) c(first = min(y, na.rm = TRUE), last = max(y, na.rm = TRUE)),
  c(first = 0, last = 0)
)
span[, 1:3]
#>       Classic fiction Fiction
#> first    1813    2021    2019
#> last     1999    2021    2022
dim(span)
#> [1] 2 9

# Validating every element of a list with one vapply() call.
fields <- list(title = "Orchard Logic", author = "Iris Fenwick", isbn = NA)
vapply(fields, is.character, logical(1))
#>  title author   isbn
#>   TRUE   TRUE  FALSE
all(vapply(fields, is.character, logical(1)))
#> [1] FALSE

Three details of the output. The result of sapply(character(), nchar) is named list(), because with USE.NAMES = TRUE the names are taken from the character input, which is empty, while vapply(character(), nchar, integer(1)) is named integer(0): pass USE.NAMES = FALSE when the names are not wanted, as in the second call on books$isbn, where they are the ISBNs themselves. The two error messages say which element broke the contract: values must be length 1, but FUN(X[[1]]) result is length 2 for a shape error, and values must be type 'character', but FUN(X[[1]]) result is type 'integer' for a type error; promotion is allowed, so a logical result is accepted for integer(1) and an integer for numeric(1), but a double is not accepted for integer(1). And a FUN.VALUE with a length greater than one and names, like c(first = 0, last = 0), gives a matrix whose rows carry those names, which turns vapply() into a typed way to compute several summaries per group. The last two lines show the validation use: one call tests each element of a list against a rule and returns a plain logical vector that all() can reduce, with no chance of a list result.

The older tutorials use sapply() everywhere, which is fine at the console where the output is inspected; in a function that other code depends on, the guess is a latent bug. The other members of the family and their simplification rules are covered on The apply Family and Functionals.

seq_along() and seq_len() instead of 1:length(x)

1:length(x) is the other idiom that works until the input is empty. The colon operator counts down when the second number is smaller, so 1:0 is c(1, 0), and a loop over 1:length(x) for an empty x runs twice, with i equal to 1 and then 0, and x[1] is NA while x[0] is empty. The fix is seq_along(x), which is integer(0) for an empty vector, so the loop body never runs, or seq_len(n) when the count comes from a number such as nrow(df). The help page recommends both over seq() with one argument, because seq(x) for a numeric x of length one is 1:x, even for seq(0), which is c(1, 0) (R documentation: seq).

source("scenario.R")

# 1:length(x) counts down when x is empty: 1:0 is c(1, 0).
x <- character()
1:length(x) # nolint: seq_linter.
#> [1] 1 0
for (i in 1:length(x)) print(i) # nolint: seq_linter.
#> [1] 1
#> [1] 0
for (i in seq_along(x)) print(i)
seq_along(x)
#> integer(0)
seq_len(length(x)) # nolint: seq_linter.
#> integer(0)

# A subset can be empty without anyone noticing.
future <- books[books$year > 2100 & !is.na(books$year), ]
nrow(future)
#> [1] 0
for (i in seq_len(nrow(future))) print(future$title[[i]])
rows <- 1:nrow(future) # nolint: seq_linter.
rows
#> [1] 1 0
future$title[rows]
#> [1] NA

# seq(x) with a single number counts from 1 to that number.
seq(5)
#> [1] 1 2 3 4 5
seq(0)
#> [1] 1 0
seq(length(x)) # nolint: seq_linter.
#> [1] 1 0
seq(c(2, 3))
#> [1] 1 2
seq_along(c(2, 3))
#> [1] 1 2

# seq_len() requires a non-negative number; the error shows a bad count.
n <- 0
1:(n - 1)
#> [1]  1  0 -1
tryCatch(seq_len(n - 1), error = conditionMessage)
#> [1] "argument must be coercible to non-negative integer"
tryCatch(seq_len(NA), error = conditionMessage)
#> [1] "argument must be coercible to non-negative integer"
seq_len(max(n - 1, 0))
#> integer(0)

# seq_along() follows the elements of the vector, of any type.
for (i in seq_along(books$title[1:3])) {
  cat(i, books$title[[i]], "\n")
}
#> 1 The Dispossessed
#> 2 Pride and Prejudice
#> 3   the quiet streets
seq_along(list(1, "a", NULL))
#> [1] 1 2 3

The data-frame case is the one that bites in practice: a filter that matches no rows produces a data frame with zero rows, nrow(future) is 0, and 1:nrow(future) is c(1, 0). A loop over those values would run twice, and the vector version of the same mistake, future$title[rows], returns NA for a title that does not exist. seq_len(nrow(future)) runs zero times. The same script shows the remaining trap of the family: seq_len() requires a non-negative whole number and rejects -1 and NA with argument must be coercible to non-negative integer, so a count computed as n - 1 needs max(n - 1, 0) when n can be zero, where 1:(n - 1) would silently count down through 1, 0, -1. The seq_linter check of lintr flags 1:length(x) and seq(length(x)) for exactly these reasons, which is why the script carries # nolint: seq_linter. on the lines that show the faulty idiom on purpose.

Comparing values: ==, identical(), all.equal() and isTRUE()

The comparison operators answer a different question from the one if() asks. == compares element by element and returns a vector of the same length as its operands, with NA wherever an operand is NA; if() needs one TRUE or FALSE. The help page of identical() makes the point directly: identical() is the way to test exact equality in if and while conditions and in logical expressions with && and ||, because it always returns a single logical value, where == returns an object like its arguments (R documentation: identical). Three functions cover the three questions a program asks:

  • Are these exactly the same object? identical(x, y). It compares type, values and attributes: identical(1L, 1) is FALSE because one is an integer and the other a double, identical(c(a = 1), 1) is FALSE because of the names, identical(NA, NA_integer_) is FALSE, and identical(NaN, NaN) is TRUE although NaN == NaN is NA.

  • Are these numbers equal within rounding error? all.equal(target, current), whose default tolerance is about 1.5e-8, the square root of the machine epsilon. It returns TRUE or a character vector describing the differences, so it must not be used directly in if().

  • Is this one value TRUE? isTRUE(x) and isFALSE(x).

The reason to prefer all.equal() for computed numbers is R FAQ 7.31, "Why doesn’t R think these numbers are equal?": only integers and fractions whose denominator is a power of two are represented exactly, every other number is rounded to about 53 binary digits, so two floating point numbers are not reliably equal unless they were computed by the same algorithm, and not always even then. The FAQ uses sqrt(2) squared as its example and recommends all.equal(), which compares with a tolerance of .Machine$double.eps ^ 0.5 (R FAQ 7.31: Why doesn’t R think these numbers are equal?).

source("scenario.R")

# == compares element by element and propagates NA; if() cannot use that.
tryCatch(
  if (NA == 1) "same", # nolint: equals_na_linter.
  error = conditionMessage
)
#> [1] "missing value where TRUE/FALSE needed"
tryCatch(if (c(1, 2) == c(1, 2)) "same", error = conditionMessage)
#> [1] "the condition has length > 1"

# identical() always returns one TRUE or FALSE, and compares exactly.
identical(c(1, 2), c(1, 2))
#> [1] TRUE
identical(1L, 1)
#> [1] FALSE
1L == 1
#> [1] TRUE
identical(c(a = 1), 1)
#> [1] FALSE
identical(NA, NA_integer_)
#> [1] FALSE
identical(NaN, NaN)
#> [1] TRUE
NaN == NaN
#> [1] NA
identical(NULL, c())
#> [1] TRUE
identical(list(1), list(1))
#> [1] TRUE
identical(books, books)
#> [1] TRUE
identical(books$isbn, normalise_isbn(books$isbn))
#> [1] FALSE

# Floating point numbers are rarely exactly equal (R FAQ 7.31).
a <- sqrt(2)
a * a == 2
#> [1] FALSE
a * a - 2
#> [1] 4.440892e-16
identical(a * a, 2)
#> [1] FALSE
0.1 + 0.2 == 0.3
#> [1] FALSE
print(0.1 + 0.2, digits = 17)
#> [1] 0.30000000000000004

# all.equal() allows a tolerance, but returns text when numbers differ.
all.equal(a * a, 2)
#> [1] TRUE
all.equal(1, 1.1)
#> [1] "Mean relative difference: 0.1"
all.equal(1, 1 + 1e-7)
#> [1] "Mean relative difference: 1e-07"
all.equal(1, 1 + 1e-9)
#> [1] TRUE
all.equal(1, 1.1, tolerance = 0.2)
#> [1] TRUE
all.equal("a", "b")
#> [1] "1 string mismatch"
all.equal(1, "a")
#> [1] "Modes: numeric, character"
#> [2] "target is numeric, current is character"
all.equal(c(a = 1), 1)
#> [1] "names for target but not for current"
all.equal(c(a = 1), 1, check.attributes = FALSE)
#> [1] TRUE
tryCatch(if (all.equal(1, 1.1)) "same", error = conditionMessage)
#> [1] "argument is not interpretable as logical"

# isTRUE(all.equal(...)) is the idiom for a TRUE or FALSE answer.
isTRUE(all.equal(a * a, 2))
#> [1] TRUE
isTRUE(all.equal(1, 1.1))
#> [1] FALSE

# isTRUE() and isFALSE() require exactly one TRUE (or FALSE), nothing else.
isTRUE(TRUE)
#> [1] TRUE
isTRUE(c(a = TRUE))
#> [1] TRUE
isTRUE(c(TRUE, TRUE))
#> [1] FALSE
isTRUE(NA)
#> [1] FALSE
isTRUE(1)
#> [1] FALSE
isTRUE("TRUE")
#> [1] FALSE
isFALSE(FALSE)
#> [1] TRUE
isFALSE(NA)
#> [1] FALSE
isFALSE(0)
#> [1] FALSE

# if (isTRUE(cond)) treats an NA condition as FALSE.
has_year <- books$year > 2000
has_year[7]
#> [1] NA
if (isTRUE(has_year[7])) "recent" else "not known to be recent"
#> [1] "not known to be recent"
if (isFALSE(has_year[7])) "old" else "not known to be old"
#> [1] "not known to be old"

# %in% never returns NA, which makes it safe inside if().
NA == "Fiction" # nolint: equals_na_linter.
#> [1] NA
NA %in% "Fiction"
#> [1] FALSE
"Fiction" %in% books$genre
#> [1] TRUE

The output of all.equal() is the part that people forget. all.equal(1, 1.1) is not FALSE but the string Mean relative difference: 0.1, and a string in if() gives argument is not interpretable as logical, so the idiom is isTRUE(all.equal(x, y)). The same tolerance applies to all.equal(1, 1 + 1e-7), which differs by more than 1.5e-8 and reports Mean relative difference: 1e-07, while a difference of 1e-9 is accepted. The text of the difference is useful in a test or in stopifnot(), which prints it, as the first script of this page showed.

isTRUE(x) is documented as the same as { is.logical(x) && length(x) == 1 && !is.na(x) && x }, so it is TRUE for exactly one logical TRUE and for nothing else: not for 1, not for "TRUE", not for NA, and not for a vector of two TRUE values. The help page adds the reason it exists: if (isTRUE(cond)) may be preferable to if (cond) because of NA (R documentation: Logic). R 3.5.0 made isTRUE() tolerant of attributes, so a named TRUE such as c(a = TRUE) now passes (the previous definition identical(x, TRUE) rejected it), and added isFALSE() defined in the same way. isFALSE(NA) is FALSE, so the pair gives a three-way test of a condition that may be NA: isTRUE(cond), isFALSE(cond), and otherwise unknown. %in% is another comparison that never returns NA and so is safe in a condition: NA %in% "Fiction" is FALSE, where NA == "Fiction" is NA.

Partial matching and how to catch it

R is generous about names, and the generosity hides typos. Three places allow a partial name. The $ operator on a list or a data frame returns the single element whose name starts with the given text. A function call matches an argument name partially when the formal comes before the dots; the manual states the three passes of the matching, exact, then partial, then positional, and that formals after ... can be matched only exactly (R-lang: Argument matching). And attr(x, "name") matches an attribute name partially unless exact = TRUE. The matching is sensible at the console, where it saves keystrokes, and a liability in a script, where books$yea works today and silently returns the wrong column the day somebody adds a year_end column, because after that the prefix is ambiguous and the result becomes NULL (the script shows two$ye returning NULL).

source("scenario.R")

# $ matches a name partially on lists and data frames, without a word.
fields <- list(year = 2019L, title = "Orchard Logic")
fields$ye
#> [1] 2019
books$yea[1:3]
#> [1] 1974 1813 2019
fields$ti
#> [1] "Orchard Logic"

# [[ is exact by default; exact = FALSE allows it, exact = NA warns.
fields[["ye"]]
#> NULL
fields[["ye", exact = FALSE]]
#> [1] 2019
tryCatch(fields[["ye", exact = NA]], warning = conditionMessage)
#> [1] "partial match of 'ye' to 'year'"

# A name that is a prefix of two elements matches neither.
two <- list(year = 2019L, year_end = 2020L)
two$ye
#> NULL
two$year
#> [1] 2019

# The three options: all default to FALSE.
getOption("warnPartialMatchDollar")
#> [1] FALSE
getOption("warnPartialMatchArgs")
#> [1] FALSE
getOption("warnPartialMatchAttr")
#> [1] FALSE

# warnPartialMatchDollar: partial matching with $.
old <- options(warnPartialMatchDollar = TRUE)
w <- tryCatch(fields$ye, warning = identity)
conditionMessage(w)
#> [1] "partial match of 'ye' to 'year'"
conditionCall(w)
#> fields$ye
class(w)
#> [1] "simpleWarning" "warning"       "condition"
tryCatch(books$yea, warning = conditionMessage)
#> [1] "partial match of 'yea' to 'year'"
fields$year
#> [1] 2019
options(old)

# warnPartialMatchArgs: partial matching of argument names.
shelf_label <- function(title, year) paste0(title, " (", year, ")")
shelf_label("Orchard Logic", yea = 2019)
#> [1] "Orchard Logic (2019)"
old <- options(warnPartialMatchArgs = TRUE)
w <- tryCatch(shelf_label("Orchard Logic", yea = 2019), warning = identity)
conditionMessage(w)
#> [1] "partial argument match of 'yea' to 'year'"
conditionCall(w)
#> shelf_label("Orchard Logic", yea = 2019)
class(w)
#> [1] "partialMatchWarning" "warning"             "condition"
shelf_label("Orchard Logic", year = 2019)
#> [1] "Orchard Logic (2019)"
options(old)

# warnPartialMatchAttr: partial matching in attr().
pages <- structure(1:3, units = "pages")
attr(pages, "uni")
#> [1] "pages"
attr(pages, "uni", exact = TRUE)
#> NULL
old <- options(warnPartialMatchAttr = TRUE)
tryCatch(attr(pages, "uni"), warning = conditionMessage)
#> [1] "partial argument match of 'uni' to 'units'"
attr(pages, "units")
#> [1] "pages"
options(old)

# The data frame accessors that do not match partially.
dd <- data.frame(year = 1:2)
dd[["ye"]]
#> NULL
tryCatch(dd[, "ye"], error = conditionMessage)
#> [1] "undefined columns selected"
dd[, "year"]
#> [1] 1 2

Three options turn the silent matching into a warning, and all three default to FALSE (R documentation: options):

Option Reports partial matching in Warning text

warnPartialMatchDollar

extraction with $

partial match of 'ye' to 'year'

warnPartialMatchArgs

argument matching in calls

partial argument match of 'yea' to 'year'

warnPartialMatchAttr

extraction of attributes with attr()

partial argument match of 'uni' to 'units'

In the output above, the $ warning has class simpleWarning and the call fields$ye, the argument warning has the class vector partialMatchWarning, warning, condition and the call of the function that received the argument, and the attribute warning reuses the wording of the argument warning although it comes from attr(). Set an option for the duration of a test run or in the .Rprofile of a development session (the file is described on Sessions, Workspaces and Startup), and restore the old values afterwards, as the script does with old <- options(...) and options(old). A test suite that runs with options(warn = 2) as well turns every partial match into an error. [[ is exact by default, so fields[["ye"]] is NULL; exact = FALSE allows a partial match; and exact = NA allows it but warns, which is the targeted way to find the places in an old script that rely on it. On data frames, dd[["ye"]] is NULL and dd[, "ye"] is an error, undefined columns selected, so the bracket forms are strict while $ is not. R 3.1.1 changed the data frame case of $ to warn only when warnPartialMatchDollar is TRUE: before that, $ on a data frame warned about every partial match.

Fail fast with classed custom conditions

stopifnot() and stop("message") produce errors that differ only in their text, and text is a poor interface. A caller who wants to skip a row with a bad ISBN but let every other error through would have to search the message for "invalid ISBN", which breaks when someone fixes the wording. The scenario’s own validate_book() has this limitation on purpose: its errors are plain simpleError conditions with call. = FALSE, so conditionCall() is NULL and only conditionMessage() tells them apart.

R lets a condition be an object with a class and with data. errorCondition(message, ..., class, call) and its sibling warningCondition() were added in R 3.6.0 to build such objects: the first argument is the message, any further named arguments become fields of the condition, class is prepended to the standard class vector, and call records the call to show. stop(cond) signals the object, and tryCatch() selects a handler by class (R documentation: conditions). The class hierarchy gives the same specific-to-general selection that exceptions give in other languages: the most specific class first, then a package-wide parent class such as shelf_error, then the built-in error. The full machinery is on Conditions and Error Handling; the script below is the validation side of it.

source("scenario.R")

# The scenario's validate_book() signals a plain error: only the text tells
# the callers apart.
e <- tryCatch(book("Orchard Logic", isbn = "9780306406158"), error = identity)
class(e)
#> [1] "simpleError" "error"       "condition"
conditionMessage(e)
#> [1] "invalid ISBN-13: 9780306406158"
conditionCall(e)
#> NULL

# A classed condition carries a class to catch and the offending data.
shelf_abort <- function(message, class, ..., call = sys.call(-1L)) {
  stop(errorCondition(message, ..., class = c(class, "shelf_error"),
                      call = call))
}

check_book <- function(isbn) {
  stopifnot("isbn must be a single string" = is.character(isbn) &&
              length(isbn) == 1L)
  if (is.na(isbn) || !is_valid_isbn13(isbn)) { # nolint: object_usage_linter.
    shelf_abort(paste0("invalid ISBN-13: ", isbn),
                class = "shelf_invalid_isbn", isbn = isbn)
  }
  invisible(isbn)
}

e <- tryCatch(check_book("9780306406158"), error = identity)
class(e)
#> [1] "shelf_invalid_isbn" "shelf_error"        "error"
#> [4] "condition"
conditionMessage(e)
#> [1] "invalid ISBN-13: 9780306406158"
conditionCall(e)
#> check_book("9780306406158")
e$isbn
#> [1] "9780306406158"
print(e)
#> <shelf_invalid_isbn in check_book("9780306406158"): invalid ISBN-13: 9780306406158>

# The caller chooses by class, from the most specific to the most general.
import_one <- function(isbn) {
  tryCatch(
    {
      check_book(isbn)
      "imported"
    },
    shelf_invalid_isbn = \(e) paste("skipped", e$isbn),
    shelf_error = \(e) "other shelf problem"
  )
}
import_one(books$isbn[1])
#> [1] "imported"
import_one(books$isbn[13])
#> [1] "skipped 978-0-01-100112-9"
import_one(books$isbn[9])
#> [1] "skipped "

# A programming error is not a shelf error and keeps its own type.
tryCatch(import_one(42), error = conditionMessage)
#> [1] "isbn must be a single string"

# Collect the failures of a whole column instead of stopping at the first.
results <- vapply(books$isbn, import_one, character(1), USE.NAMES = FALSE)
sum(startsWith(results, "skipped"))
#> [1] 2
books$title[startsWith(results, "skipped")]
#> [1] "Rivers of Glass" "Orchard Logic"

# inherits() works on the class vector of the condition.
inherits(e, "shelf_error")
#> [1] TRUE
inherits(e, "shelf_invalid_isbn")
#> [1] TRUE
inherits(e, "simpleError")
#> [1] FALSE
class(e)
#> [1] "shelf_invalid_isbn" "shelf_error"        "error"
#> [4] "condition"

Four design points in the script. The helper shelf_abort() is the single place that builds the condition, so every error of the package has the parent class shelf_error and the same shape. Its default call = sys.call(-1L) records the call of the function that called the helper, check_book(...), and not the call of the helper itself, which is what stop() does for a plain message. The data that triggered the error travels as a field, e$isbn, so the handler reports the offending value without parsing a message. And the two layers stay separate: the type check at the top of check_book() is a stopifnot() that raises a plain error for a programming mistake, which import_one(42) shows passing through the shelf handlers untouched, and only the data error is classed. The last block applies the pattern to a whole column: vapply() over books$isbn, each element wrapped by a handler that returns a status string, finds the two bad rows (the empty ISBN and the one with a wrong check digit) in one pass without stopping at the first, the situation that a plain stop() cannot express without text matching.

A custom class is part of a package’s interface: document it, keep the names stable, and prefix them with the package name to avoid clashes (shelf_invalid_isbn, not invalid_isbn). R 4.4.0 made tools::xgettext() stop extracting the class names in errorCondition() and warningCondition() calls as translatable strings, since they are identifiers and not messages. R-devel NEWS (4.7.0, under development, not available in R 4.6.1) lists two new built-in classes for failed lookups, objectNotFoundError for an object that is not found and functionNotFoundError for a function that is not found, and says that the classes used internally for signalling some errors are now documented in ?conditions; that would let a handler select a missing function without parsing the message, and it is mentioned here only as upcoming (R-devel NEWS).

Evolving an API: .Deprecated() and .Defunct()

Validation also protects the callers of an old function. A function that is going to be removed should say so for a release or two, and then fail with an explanation, and base R has a pair of helpers for the two steps. .Deprecated(new, package, msg, old) signals a warning whose default text names the old function and the one to use instead, and tells the user to see help("Deprecated"); .Defunct() signals an error for a function that has been retired (R documentation: Deprecated). Since R 3.6.0 the warning has class deprecatedWarning and the error class defunctError, so a test or a caller can select them without matching text, and the condition keeps the fields old, new and package.

# .Deprecated() signals a warning of class "deprecatedWarning" (R 3.6.0).
new_fn <- function(x) x + 1
old_fn <- function(x) {
  .Deprecated("new_fn")
  new_fn(x)
}
w <- tryCatch(old_fn(1), warning = identity)
class(w)
#> [1] "deprecatedWarning" "warning"           "condition"
conditionMessage(w)
#> [1] "'old_fn' is deprecated.\nUse 'new_fn' instead.\nSee help(\"Deprecated\")"
conditionCall(w)
#> old_fn(1)
suppressWarnings(old_fn(1))
#> [1] 2

# A custom message, and the fields the condition keeps.
shelf_old <- function() {
  .Deprecated(
    new = "shelf_new", package = "shelf", old = "shelf_old",
    msg = "shelf_old() will be removed in shelf 2.0"
  )
}
w <- tryCatch(shelf_old(), warning = identity)
conditionMessage(w)
#> [1] "shelf_old() will be removed in shelf 2.0"
c(w$old, w$new, w$package)
#> [1] "shelf_old" "shelf_new" "shelf"

# .Defunct() is the next step: an error of class "defunctError".
shelf_gone <- function() .Defunct("shelf_new", package = "shelf")
e <- tryCatch(shelf_gone(), error = identity)
class(e)
#> [1] "defunctError" "error"        "condition"
conditionMessage(e)
#> [1] "'shelf_gone' is defunct.\nUse 'shelf_new' instead.\nSee help(\"Defunct\") and help(\"shelf-defunct\")."

# Callers can handle one without touching the others.
res <- withCallingHandlers(
  old_fn(1),
  deprecatedWarning = \(w) invokeRestart("muffleWarning")
)
res
#> [1] 2

Base R follows the same two steps for its own functions. is.R(), which asked whether code ran in R rather than in S-PLUS, was deprecated in R 4.4.0 and is defunct since R 4.5.0, whose NEWS also says that the environment variable _R_DEPRECATED_IS_R_ no longer has any effect. A guard such as exists("is.R") && is.R() in older code now stops with 'is.R' is defunct., and the repair is to delete the test (R documentation: base-defunct; What’s New and Migrating Older Code).

The wording of the default message is built from the name of the function that called .Deprecated() unless old is given, which is why shelf_old() passes old = "shelf_old" and package = "shelf" explicitly when the function lives in a package. The msg argument replaces the whole text for a case like a renamed argument. A common policy is to deprecate in one release, keep the function working while it warns, make it defunct in a later one, and remove it after that, with an entry in the package NEWS at each step; the final block shows how a caller that knows about the deprecation and wants the old behaviour for now can mute exactly that warning class with a calling handler and the muffleWarning restart, leaving other warnings visible. The mechanism is described on Conditions and Error Handling.

The checkmate package

Writing the checks by hand for every argument gets repetitive, and several packages provide a vocabulary for it. This section covers one of them, checkmate (checkmate package documentation, version 2.3.4 here), a CRAN package of argument checks, a substantial part of which is written in C so that the checks can sit in frequently called functions. It is optional: everything above is base R, and a package that wants to stay dependency-free can ship a few of its own is_* helpers. For each kind of check checkmate provides three functions: assert_*() stops with an informative message that names the checked expression and returns its argument invisibly, check_*() returns TRUE or the text of the problem, and test_*() returns TRUE or FALSE. The camel-case spellings (assertString(), checkString()) are equivalent. qassert() states a whole rule in a short string, "I1" for one integer, "N1" for one number, with optional bounds. Functions such as assert_choice(), assert_data_frame() and assert_class() cover the cases that match.arg(), is.data.frame() and inherits() handle in base R, and assert() combines several checks with "or" semantics (checkmate package documentation: assert).

library(checkmate)
source("scenario.R")

# assert_*() functions stop with a message that names the argument.
add_pages <- function(pages, title) {
  assert_integerish(pages, lower = 1, len = 1, any.missing = FALSE)
  assert_string(title, min.chars = 1)
  invisible(TRUE)
}
add_pages(300, "Orchard Logic")
e <- tryCatch(add_pages("300", "Orchard Logic"), error = identity)
conditionMessage(e)
#> [1] "Assertion on 'pages' failed: Must be of type 'integerish', not 'character'."
conditionCall(e)
#> add_pages("300", "Orchard Logic")
class(e)
#> [1] "simpleError" "error"       "condition"
tryCatch(add_pages(0, "Orchard Logic"), error = conditionMessage)
#> [1] "Assertion on 'pages' failed: Element 1 is not >= 1."
tryCatch(add_pages(1.5, "Orchard Logic"), error = conditionMessage)
#> [1] "Assertion on 'pages' failed: Must be of type 'integerish', but element 1 is not close to an integer."
tryCatch(add_pages(c(1, 2), "Orchard Logic"), error = conditionMessage)
#> [1] "Assertion on 'pages' failed: Must have length 1, but has length 2."
tryCatch(add_pages(10, ""), error = conditionMessage)
#> [1] "Assertion on 'title' failed: All elements must have at least 1 characters, but element 1 has 0 characters."

# check_*() return TRUE or a message; test_*() return TRUE or FALSE.
check_integerish(5)
#> [1] TRUE
check_integerish(5.5)
#> [1] "Must be of type 'integerish', but element 1 is not close to an integer"
test_integerish(5)
#> [1] TRUE
test_integerish(5.5)
#> [1] FALSE

# assert_*() returns its argument invisibly, so it can sit inside a pipeline.
pages <- assert_integerish(c(310L, 288L))
pages
#> [1] 310 288

# qassert() states the rule in a short string.
qassert(5L, "I1")
tryCatch(qassert("a", "I1"), error = conditionMessage)
#> [1] "Assertion on '\"a\"' failed. Must be of class 'integer', not 'character'."
tryCatch(qassert(c(1, 2), "N1"), error = conditionMessage)
#> [1] "Assertion on 'c(1, 2)' failed. Must be of length == 1, but has length 2."

# Other checks used on the shelf scenario.
tryCatch(
  assert_choice("lost", c("all", "out", "overdue")),
  error = conditionMessage
)
#> [1] "Assertion on '\"lost\"' failed: Must be element of set {'all','out','overdue'}, but is 'lost'."
tryCatch(assert_data_frame(books, ncols = 3), error = conditionMessage)
#> [1] "Assertion on 'books' failed: Must have exactly 3 cols, but has 6 cols."
tryCatch(assert_class(books, "book"), error = conditionMessage)
#> [1] "Assertion on 'books' failed: Must inherit from class 'book', but has class 'data.frame'."

The messages follow a fixed pattern, Assertion on '<expression>' failed: <what is wrong>., and the call recorded in the condition is the function that contains the assertion, as with stopifnot(). assert_integerish() accepts a double that holds a whole number, which is what a user typing 300 at the console supplies, and rejects 1.5 with a message that says the element is not close to an integer; the arguments lower, len and any.missing express the range, the length and the NA rule that the hand-written is_count() above checks with four operands. The second line, assert_string(title, min.chars = 1), states in one call that the title is a single string that is not missing and not empty. The classes are plain simpleError conditions, so tryCatch() can select them only by message, not by class, which is one reason to use the classed conditions of the previous section for data errors and to keep checkmate for programming errors. Further documentation: checkmate package documentation: qassert, checkmate package documentation: checkString and checkmate package documentation: checkChoice.

Pitfalls collected

The sections above each carry their own pitfalls; this table puts the main ones next to the help page or FAQ entry that explains them.

Symptom Cause and remedy Where

stopifnot(x > 0) passes for an empty vector

A logical vector of length zero has no element that is not TRUE. Add length(x) > 0L as its own named condition.

?stopifnot

stopifnot(length(x)) fails although if (length(x)) would run

Numbers are not TRUE, even where if() coerces them. Compare: length(x) > 0L.

?stopifnot

stopifnot(x > 0) passes for the string "a"

The comparison converts 0 to text and compares strings. Put is.numeric(x) first in the same call.

This page

A named condition in stopifnot(exprs = { }) prints the expression, not the name

Names are used as messages only in the ordinary argument form.

?stopifnot

new_book(year = 2019) fails with is.integer(year) is not TRUE

2019 is a double. Use 2019L, as.integer(), or the book() helper.

This page

is.numeric() is FALSE for a date, a difftime or a factor

Classes can opt out of the test. Check inherits(x, "Date") for dates; convert a factor deliberately.

?numeric

if (class(x) == "matrix") fails with the condition has length > 1

A matrix has two classes since R 4.0.0. Use inherits(x, "matrix").

?class

if (x > 0 && y) fails with 'length = 2' in coercion to 'logical(1)'

&& and || accept only length-one operands since R 4.3.0. Reduce with all() or any(), or check the length first.

NEWS 4.3.0

if (all.equal(a, b)) fails with argument is not interpretable as logical

all.equal() returns text when the values differ. Use isTRUE(all.equal(a, b)).

R FAQ 7.31

if (cond) fails with missing value where TRUE/FALSE needed

The condition is NA. Use isTRUE(cond) when NA should count as "no", or test with is.na() first.

?Logic

A loop over 1:length(x) or 1:nrow(df) misbehaves on empty input

1:0 is c(1, 0). Use seq_along(x) and seq_len(nrow(df)).

?seq

seq_len(n - 1) fails with argument must be coercible to non-negative integer

n was zero. Use seq_len(max(n - 1, 0)) or restructure the loop.

?seq

sapply() returns a list where a vector was expected

No results to simplify, or results of unequal length. Use vapply() with a declared FUN.VALUE.

?lapply

books$yea returns a column and nobody notices

$ matches partially. Turn on options(warnPartialMatchDollar = TRUE) while testing, or use [[.

?options

A function call with an abbreviated argument name works until the signature changes

Formals before the dots match partially. Spell names out; options(warnPartialMatchArgs = TRUE) finds the uses.

R-lang: Argument matching

A test fails after upgrading R because it compares an error message

The help page of match.arg() warns that the messages are liable to change. Test that an error occurs, or test its class.

?match.arg

A handler cannot tell two kinds of error apart

Both are plain simpleError conditions. Signal a classed condition with errorCondition().

?conditions

Version notes

Version Change Source

R 3.1.1

The warning for partial matching with $ on a data frame is given only when options("warnPartialMatchDollar") is TRUE.

NEWS 3.1.1

R 3.4.0

stopifnot() reports the differences found by all.equal() when that comparison is not true.

NEWS 3.4.0

R 3.5.0

stopifnot(exprs = { }) is added and the expressions are evaluated sequentially; isTRUE() is more tolerant (a named TRUE passes) and isFALSE() is new.

NEWS 3.5.0

R 3.6.0

errorCondition() and warningCondition() are added; .Deprecated() signals a warning of class deprecatedWarning and .Defunct() an error of class defunctError; stopifnot() has considerably less overhead.

NEWS 3.6.0, ?stopifnot

R 4.0.0

stopifnot() accepts names as messages and gets exprObject; matrices inherit from class "array", so class(m) has length two.

NEWS 4.0.0

R 4.2.0

if() and while() with a condition of length greater than one are an error and && and || with such an operand warn; the message of match.arg("", ...) is improved and the wording of the match.arg() errors changed.

NEWS 4.2.0, ?match.arg

R 4.3.0

&& and || with an operand of length greater than one are an error; stopifnot() reports the differences of any call whose name partially matches all.equal.

NEWS 4.3.0, ?stopifnot

R 4.4.0

%||% is added to base R; is.atomic(NULL) is FALSE; tools::xgettext() stops extracting the class names of errorCondition() and warningCondition() calls.

NEWS 4.4.0

R 4.5.0

The class missingArgError (with the subclass getMissingError for get()) is documented in the NEWS for errors about missing arguments.

NEWS 4.5.0

R 4.6.0

match.arg() gets several.ok = "all".

NEWS 4.6.0

R-devel (4.7.0, under development)

Errors for a missing object or function are signalled with the classes objectNotFoundError and functionNotFoundError, and the classes used internally for some errors are documented in ?conditions. They do not exist in R 4.6.1, so no example on this page uses them.

R-devel NEWS

The entries for R 4.0.0 and later are in R NEWS: Changes in R 4.0.0 to 4.6.0, and the R-devel entries in the development NEWS; they may change before 4.7.0 is released.

Where older tutorials differ

Many pages on the web predate R 4.0.0 and some predate R 3.5.0. The table lists what they do differently from the current practice used on this page.

Older tutorial Current practice Why Version

if (!is.numeric(x)) stop("x must be numeric") repeated for every argument, or a package for assertions

stopifnot("x must be numeric" = is.numeric(x)) with the checks together at the top

the message can be customised in base R, and several checks share one call

R 4.0.0

Several nested if statements to check the arguments one after the other

stopifnot(exprs = { }) with one expression per line

the expressions are evaluated in order and the first failure is reported

R 3.5.0

sapply(x, f) inside functions

vapply(x, f, numeric(1)) with a declared result type

no guessing, and empty input gives an empty vector of the right type

always

for (i in 1:length(x)) and 1:nrow(df)

seq_along(x) and seq_len(nrow(df))

1:0 counts down

always

class(x) == "matrix" and class(x)[1] == "foo"

inherits(x, "matrix")

the class has several elements, for matrices since R 4.0.0

R 4.0.0

if (x == y) for computed numbers

isTRUE(all.equal(x, y)) or abs(x - y) < tol

floating point rounding (R FAQ 7.31)

always

isTRUE(x) described as identical(x, TRUE), so isTRUE(c(a = TRUE)) is FALSE

isTRUE() accepts a named TRUE; isFALSE() exists

the definition was relaxed

R 3.5.0

if (c(TRUE, FALSE)) and x && y with vectors, which used the first element with a warning

all(), any() or a length check

these are errors now

R 4.2.0 and R 4.3.0

if (is.null(x)) y else x

x %||% y

one operator in base R

R 4.4.0

match.arg(x, several.ok = TRUE) followed by a hand-written length check

match.arg(x, several.ok = "all")

it reports the non-matching element with the usual message

R 4.6.0

tryCatch(..., error = \(e) if (grepl("invalid ISBN", conditionMessage(e))) ...)

a classed condition from errorCondition() and a handler for its class

the wording of a message can change; a class is part of the interface

R 3.6.0

warning("old_fn is deprecated") written by hand

.Deprecated("new_fn")

the warning has a class and a standard text

R 3.6.0

T and F in checks, such as isTRUE(x == T)

TRUE and FALSE

T and F are ordinary variables that can be overwritten

always

is.atomic(x) used to test for NULL or for any vector

is.null(x) for NULL; is.vector(x) or specific predicates for vectors

is.atomic(NULL) is FALSE since R 4.4.0

R 4.4.0

For the full list of changes by release see What’s New and Migrating Older Code.

Where to go next