Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@
^compile_commands\.json$
.*\.clangd$
^[\.]?air\.toml$
^skills$
^.claude$
1 change: 1 addition & 0 deletions .claude/skills
1 change: 1 addition & 0 deletions R/standalone-types-check.R
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ check_symbol <- function(
)
}

# Do we need this?
check_arg <- function(
x,
...,
Expand Down
279 changes: 279 additions & 0 deletions skills/types-check/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
---
name: types-check
description: Validate function inputs in R using a standalone file of check_* functions. Use when writing exported R functions that need input validation, or reviewing existing validation code.
---

# Input Validation in R Functions

This skill covers patterns for validating function inputs using rlang's standalone file of `check_*` functions or reviewing existing validation code.

## About the Standalone File

The `check_*` functions come from <https://github.com/r-lib/rlang/blob/main/R/standalone-types-check.R>, a standalone file that can be vendored into any R package. This means:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for this section, you could just say "if R/standalone-types.check.R does not exist, run usethis::use_standalone("r-lib/rlang", "types-check") to add it.

Copy link
Member Author

@lionel- lionel- Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tightened that section a bit. But mentioning "If the file does not exist" might prompt the agent to repeatedly use a tool call to look up for it, which would be wasteful since importing is a one time thing. The current wording hopefully has an implication of reaction: if it sees diagnostics or runtime errors, then it can fix these by importing.

I think it's also helpful to mention the rlang dependency so it doesn't try to use them in zero-deps packages.


- **Not exported**: These functions are internal utilities, not part of your package's public API, nor rlang's
- **Use usethis to import**: If you see these helpers are missing, run `usethis::use_standalone("r-lib/rlang", "types-check")` to add the file in your package. Call again to update it.
- **Dependency**: Requires a sufficiently new version of rlang in `Imports`. The exact minimal version is inserted automatically by `usethis::use_standalone()`. These checkers are not a good fit for zero-dependencies packages.

## Core Principles

### Error messages

The `check_*` functions produce clear, actionable error messages crafted by rlang:

```r
check_string(123)
#> Error: `123` must be a single string, not the number 123.

check_number_whole(3.14, min = 1, max = 10)
#> Error: `3.14` must be a whole number, not the number 3.14.
```

### Performance

Some checkers are implemented in C for minimal overhead:
- `check_bool()`
- `check_number_whole()`
- `check_number_decimal()`

This makes validation fast enough to use even in performance-sensitive code.
But don't check in a very tight loop unless absolutely needed.

## When to Validate Inputs

**Validate at entry points, not everywhere.**

Input validation should happen at the boundary between user code and your package's internal implementation, typically in exported functions that accept user data. Once inputs are validated at these entry points, internal helper functions can trust the data they receive without checking again.

A good analogy to keep in mind is gradual typing. Think of input validation like TypeScript type guards. Once you've validated data at the boundary, you can treat it as "typed" within your internal functions. Additional runtime checks are not needed. The entry point validates once, and all downstream code benefits.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels very aimed at an LLM since most other readers wouldn't find the reference to typescript helpful.


Exception: Validate when in doubt. Do validate in internal functions if:
- The cost of invalid data is high (data corruption, security issues)
- The function or context is complex and you want defensive checks

### Entry points (validate here)

- **Exported functions**: Functions users call directly
- **Functions accepting user data**: Even internal functions if they directly consume user input, or external data (e.g. unserialised data)

```r
# Exported function: VALIDATE
#' @export
create_report <- function(title, n_rows) {
check_string(title)
check_number_whole(n_rows, min = 1)

# Now call helpers with validated data
data <- generate_data(n_rows)
format_report(title, data)
}
```

### Internal helpers (don't validate)

Once data is validated at the entry point, internal helpers can skip validation:

```r
# Internal helper: NO VALIDATION NEEDED
generate_data <- function(n_rows) {
# n_rows is already validated, just use it
data.frame(
id = seq_len(n_rows),
value = rnorm(n_rows)
)
}

# Internal helper: NO VALIDATION NEEDED
format_report <- function(title, data) {
# title and data are already validated, just use them
list(
title = title,
summary = summary(data),
rows = nrow(data)
)
}
```

Note how the `data` generated by `generate_data()` doesn't need validation either. Internal code creating data in a trusted way (e.g. because it's simple or because it's covered by unit tests) doesn't require internal checks.

## Early input checking

Always validate inputs at the start of user-facing functions, before doing any work:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this duplicative of the previous section?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm not completely duplicative. I think it's useful to be very clear about the expected location of these helpers.


```r
my_function <- function(x, name, env = caller_env()) {
check_logical(x)
check_name(name)
check_environment(env)

# ... function body
}
```

Benefits:

- This self-documents the types of the arguments
- Eager evaluation also reduces the risk of confusing lazy evaluation effects

## Choosing the Right Checker

### Scalars (single values)

For atomic vectors, use scalar checkers when arguments parameterise the function (configuration flags, names, single counts), rather than represent vectors of user data. They assert a single value.

- `check_bool()`: Single TRUE/FALSE (use for flags/options)
- `check_string()`: Single string (allows empty `""` by default)
- `check_name()`: Single non-empty string (for variable names, symbols as strings)
- `check_number_whole()`: Single integer-like numeric value
- `check_number_decimal()`: Single numeric value (allows decimals)

By default, scalar checkers do _not_ allow `NA` elements (`allow_na = FALSE`). Set `allow_na = TRUE` when missing values are allowed.

Scalar checkers also include checks for non-vector inputs:

- `check_symbol()`: A symbol object
- `check_call()`: A defused call expression
- `check_environment()`: An environment object
- `check_function()`: Any function (closure, primitive, or special)
- `check_closure()`: An R function specifically (not primitive/special)
- `check_formula()`: A formula object

### Vectors

- `check_logical()`: Logical vector of any length
- `check_character()`: Character vector of any length
- `check_data_frame()`: A data frame object

By default, vector checkers allow `NA` elements (`allow_na = TRUE`). Set `allow_na = FALSE` when missing values are not allowed.

## Optional values: `allow_null`

Use `allow_null = TRUE` when `NULL` represents a valid "no value" state, similar to `Option<T>` in Rust or `T | null` in TypeScript:

```r
# NULL means "use default timeout"
check_number_decimal(timeout, allow_null = TRUE)
```

The tidyverse style guide recommends using `NULL` defaults instead of `missing()` defaults, so this pattern comes up often in practice.

## Missing values: `allow_na`

Use `allow_na = TRUE` when `NA` is semantically meaningful for your use case:

- **For scalars**: Allows the value itself to be `NA`
- **For vectors**: Allows the vector to contain `NA` elements (this is the default for vector checkers like `check_character()` and `check_logical()`)

```r
# NA means "unknown" - semantically valid
check_string(name, allow_na = TRUE)

# Don't allow missing values in required data
check_character(ids, allow_na = FALSE)
```

## Bounds Checking for Numbers

Use `min` and `max` arguments for range validation:

```r
check_number_whole(
n,
min = 1,
max = 100
)
```

Additional number options:
- `allow_infinite = TRUE`: Allow Inf/-Inf (default `TRUE` for decimals, `FALSE` for whole numbers)

## When to Use `arg` and `call` Parameters

Understanding when to pass `arg` and `call` is critical for correct error reporting.

### Entry point functions: DON'T pass `arg`/`call`

When validating inputs directly in an entry point function (typically exported functions), **do not** pass `arg` and `call` parameters. The default parameters `caller_arg(x)` and `caller_env()` will automatically pick up the correct argument name and calling environment.

```r
# Entry point: let defaults work
#' @export
my_function <- function(x, name) {
check_string(x) # Correct! Defaults capture user's context
check_name(name) # Correct!

# ... function body
}
```

### Check wrapper functions: DO pass `arg`/`call`

When creating a wrapper or helper function that calls `check_*` functions on behalf of another function, you **must** propagate the caller context. Otherwise, errors will point to your wrapper function instead of the actual entry point.

Without proper propagation, error messages show the wrong function and argument names:

```r
# WRONG: errors will point to check_positive's definition
check_positive <- function(x) {
check_number_whole(x, min = 1)
}

my_function <- function(count) {
check_positive(count)
}

my_function(-5)
#> Error in `check_positive()`: # Wrong! Should say `my_function()`
#> ! `x` must be a whole number larger than or equal to 1. # Wrong! Should say `count`
```

With proper propagation, errors correctly identify the entry point and argument:

```r
# CORRECT: propagates context from the entry point
check_positive <- function(x, arg = caller_arg(x), call = caller_env()) {
check_number_whole(x, min = 1, arg = arg, call = call)
}

my_function <- function(count) {
check_positive(count)
}

my_function(-5)
#> Error in `my_function()`: # Correct!
#> ! `count` must be a whole number larger than or equal to 1. # Correct!
```

## Other useful checkers

Exported from other packages:

- `rlang::arg_match()`: Validates enumerated choices. Partial matching is an error unlike `base::match.arg()`. Use when an argument must be one of a known set of strings.

```r
# Validates and returns the matched value
my_plot <- function(color = c("red", "green", "blue")) {
color <- rlang::arg_match(color)
# ...
}

my_plot("redd")
#> Error in `my_plot()`:
#> ! `color` must be one of "red", "green", or "blue", not "redd".
#> ℹ Did you mean "red"?
```

- `rlang::check_required()`: Nice error message if required argument is not supplied.

- `vctrs::obj_check_list()` checks that input is considered a list in the vctrs sense:
- A bare list with no class
- A list explicitly inheriting from `"list"`.

- `vctrs::obj_check_vector()` checks that input is considered a vector in the vctrs sense:
- A base atomic type
- A list in the vctrs sense
- An object with a `vec_proxy()` method

- `vctrs::vec_check_size(x, size)` tests if vector `x` has size `size`, and throws an informative error if it doesn't.

- `vctrs::list_check_all_vectors()` and `vctrs::list_check_all_size()` check that inputs are lists containing only vectors, or only vectors of a given size.