| name | r-package-coding |
| description | R package development standards for AI coding agents. Use when writing R package code (not scripts/analysis), implementing S3 objects, writing roxygen2 documentation, or working with checkmate/cli/purrr/vctrs. Covers naming conventions, pure functions, validation patterns, and package workflow. |
R Package Development
Standards for R package development with minimal dependencies.
Dependencies
Preferred packages (must earn their place):
checkmate - input validation
cli - user messaging
purrr - functional patterns
vctrs - vector classes
Avoid tidyverse in core packages.
Depends / Imports / Suggests / Enhances
A package appears in exactly one field. Decision rules:
- Imports: default for required namespaces. Access with
:: or @importFrom.
Never library()/require() inside package code.
- Depends: only when the user needs the package attached to the search path
when calling yours. Almost always wrong — prefer Imports.
- Suggests: optional — used only in examples, tests, vignettes, or code paths
gated by
requireNamespace(). Package must function without them.
- Enhances: methods for another package's classes, accessed via
::.
- LinkingTo: C/C++ headers only. Do NOT also list in Depends/Imports.
Suggests discipline
Every use of a Suggested package must be guarded. If a package appears in
Suggests but you find an unguarded :: call or @importFrom pointing to it,
either gate the call or move the package to Imports.
if (!requireNamespace("pkg", quietly = TRUE)) {
cli::cli_abort("Install {.pkg pkg} to use this feature.")
}
pkg::do_thing()
skip_if_not_installed("pkg")
Verify discipline with _R_CHECK_DEPENDS_ONLY_=true (installs only hard deps)
and _R_CHECK_FORCE_SUGGESTS_=false (simulates missing Suggests).
Design Principles
- Top-down design: Design before coding; decompose problems into subtasks, then implement
- DRY: One authoritative location for each piece of logic—changes propagate automatically
- Single responsibility: Each function does one thing well
Naming Conventions
Functions: snake_case with consistent prefixes
S3 methods: generic.class (e.g., plot.tf, smooth.tfd)
Variables: Descriptive, plurals for collections (evaluations, arg_values)
Function Design
Standard Structure
my_function <- function(data, arg = NULL, verbose = TRUE) {
assert_numeric(data)
arg <- arg %||% seq_along(data)
result <- process(data, arg)
result
}
Pure Functions (Critical)
All inputs must be explicit arguments—no globals, no hidden dependencies.
threshold <- 0.05
filter_sig <- function(data) filter(data, p < threshold)
filter_sig <- function(data, threshold = 0.05) filter(data, p < threshold)
Style Rules
- Native pipe
|> (not %>%)
- Lambda
\(x) (not function(x))
- ~50 lines max per function
- Required args first, optional with defaults after
- Cyclomatic complexity < 10 per function
- Run
air format . after every major edit (not just before commit). A pre-commit hook enforces this, so formatting early avoids wasted cycles.
Long Parameter Lists
Group related arguments into control objects:
fit_model <- function(data, method, tol, max_iter, verbose, trace, ...) { }
fit_model <- function(data, method, control = list(tol = 1e-6, max_iter = 100)) {
control <- modifyList(list(tol = 1e-6, max_iter = 100), control)
}
S3 Objects
new_my_class <- function(data, meta) {
structure(data, meta = meta, class = "my_class")
}
my_class <- function(data, ...) {
assert_numeric(data)
new_my_class(data, process_meta(...))
}
my_generic <- function(x, ...) UseMethod("my_generic")
my_generic.default <- function(x, ...) .NotYetImplemented()
my_generic.my_class <- function(x, ...) { ... }
Use vctrs for vector-like classes; implement vec_ptype2() and vec_cast().
Prefer S3 Dispatch Over Type-Checking
Replace if-else chains with S3 methods:
process <- function(x) {
if (is.character(x)) {
} else if (is.numeric(x)) {
} else if (is.list(x)) {
}
}
process <- function(x) UseMethod("process")
process.character <- function(x) { ... }
process.numeric <- function(x) { ... }
process.list <- function(x) { ... }
process.default <- function(x) cli::cli_abort("Unsupported type: {.cls {class(x)}}")
Benefits: extensible, clearer, each method is self-contained.
Validation & Messaging
checkmate at Boundaries
assert_numeric(x, any.missing = FALSE, finite = TRUE)
assert_character(names, min.len = 1, unique = TRUE)
assert_choice(method, c("linear", "spline"))
assert_count(order, positive = TRUE)
cli for Messages
cli::cli_abort(c(
"Problem description",
"i" = "How to fix it"
))
cli::cli_warn("!" = "Something unexpected")
cli::cli_inform("i" = "Using default {.code k = {k}}")
Formatting: {.arg x}, {.fn func}, {.cls class}, {.val value}
Documentation (roxygen2)
Cross-reference with [function_name()]. Document why, not just what.
Conditional examples
Four forms with distinct semantics — pick the right one:
| Tag | Runs during R CMD check? | Runs on CRAN pretest? | When to use |
|---|
| (bare) | yes | yes | default |
\donttest{} | not by default; yes with --run-donttest | yes (CRAN runs donttest) | slow but correct (>5s) |
@examplesIf cond | only if cond is TRUE | only if cond is TRUE | needs suggested pkg, network, etc. |
\dontrun{} | never | never | cannot run in CI (interactive GUI, needs credentials, writes user files) |
\dontrun{} is heavily over-used and CRAN reviewers routinely push back on it.
Default to \donttest{} or @examplesIf — these preserve checkable coverage.
Mathematical notation: use \eqn{LaTeX}{ASCII} two-arg form — the second arg
is the plain-text fallback shown in the terminal help viewer, avoiding
non-ASCII issues:
Functional Patterns (purrr)
results <- map(data, compute)
means <- map_dbl(data, mean, na.rm = TRUE)
combined <- map2(x, y, \(a, b) a + b)
results <- pmap(list(x, y, z), \(a, b, c) a + b * c)
safe_fn <- possibly(risky_fn, otherwise = NA)
valid <- keep(items, is_valid)
Extract complex lambdas into named helpers.
Testing (testthat v3)
Basic Structure
test_that("descriptive name", {
set.seed(42)
input <- setup_data()
result <- my_function(input)
expect_equal(result, expected)
expect_error(my_function(bad), "pattern")
})
Critical Rules
1. Isolation: Each test_that() must be self-contained
set.seed(123)
global_data <- generate_data()
test_that("test 1", { ... uses global_data ... })
test_that("test 2", { ... uses global_data ... })
test_that("test 1", {
set.seed(123)
data <- generate_data()
...
})
2. Behavioral assertions, not just smoke tests
expect_s3_class(result, "my_class")
expect_true(is.list(output))
expect_equal(dim(result), c(n, p))
expect_equal(result$coefficients, expected_coefs, tolerance = 1e-6)
expect_gt(cor(fitted, true_values), 0.95)
3. Guard against edge cases
expect_lt(max(abs((a - b) / a)), 0.05)
expect_lt(max(abs(a - b)), 0.05 * max(abs(a)) + 1e-10)
expect_equal(a, b, tolerance = 0.05)
4. Verify structure, not just existence
expect_true("truth" %in% names(attributes(result)))
truth <- attr(result, "truth")
expect_true(is.list(truth))
expect_named(truth, c("eta", "etaTerms", "beta", "epsilon"))
expect_equal(dim(truth$eta), c(n, n_grid))
5. Test invariants and relationships
pred_terms <- predict(model, type = "terms")
pred_response <- predict(model, type = "response")
term_sum <- Reduce(`+`, pred_terms) + attr(pred_terms, "constant")
expect_equal(term_sum, pred_response, tolerance = 1e-6)
Skipping tests
CRAN runs tests under tight time budgets and without internet. Gate anything
slow, non-deterministic, or environment-dependent:
test_that("fits on real data", {
skip_on_cran()
skip_if_offline()
skip_if_not_installed("pkg")
skip_on_os("windows")
})
Set random seeds inside each test; they are never set automatically.
Mocking
testthat::with_mock() is deprecated. Use local_mocked_bindings() for
scoped replacements:
test_that("handles API timeout", {
local_mocked_bindings(call_api = \(...) stop("timeout"))
expect_error(fetch_data(), "timeout")
})
Helper Functions
Extract common patterns to reduce duplication:
Anti-Patterns in Testing
| Avoid | Problem | Do Instead |
|---|
Global set.seed() | Tests coupled by execution order | Seed inside each test_that() |
expect_is(x, "class") only | Doesn't verify behavior | Add structural/value checks |
| Reusing variable names | Hard to read, accidental bugs | Unique names per test |
| Commented-out expectations | False confidence in coverage | Delete or fix |
| "Does not crash" tests | No behavioral verification | Assert on outputs |
File Organization
Split large test files by topic
Workflow
devtools::load_all()
devtools::test()
Pre-Commit Requirements (MANDATORY)
Before every commit, run these steps in order. No exceptions.
- Format:
air format . in the terminal. A pre-commit hook enforces this—committing unformatted code will fail.
- Document & check (for non-trivial changes—new/changed exports, roxygen edits, NAMESPACE changes, new dependencies, test additions): run
devtools::document() then devtools::check() in R. Both must pass before committing.
- Sync pkgdown (when the public surface changes—new/renamed/removed exports, new vignettes/articles, renamed help topics): update
_pkgdown.yml so every exported topic stays in the reference: index and new articles land in the articles:/navbar entries, then run pkgdown::check_pkgdown() (fast, no full site build). This is easy to forget because devtools::check() does not flag a stale _pkgdown.yml—a missing or renamed topic only surfaces when the site builds, often much later in CI or at release.
Skip steps 2–3 only for minor internal edits (e.g., tweaking logic inside an unexported helper with no doc or test changes).
Important: Always run devtools::document() before devtools::test() or devtools::check(). This regenerates the NAMESPACE file from @importFrom and @export directives. Skipping this step after adding imports causes "object not found" errors even for exported functions.
File organization: One concept per file, helpers at top, exports at bottom.
Use # Section Name ---- or # Section Name ------ format (dashes to ~col 80) for sections.
RStudio recognizes these for code folding. Never use multi-line # ==== block headers.
Anti-Patterns
| Avoid | Do Instead |
|---|
| Modifying inputs in place | Create explicit copy |
Silent tryCatch(..., error = \(e) NULL) | Log with cli::cli_warn() |
| Magic numbers | Named constants |
| Complex inline lambdas | Named helper functions |
| Dead/commented-out code | Delete (Git has history) |
| Long functions (>50 lines) | Extract subtasks into helpers |
| Long parameter lists | Group into control object |
| Type-checking if-else chains | Use S3 methods |
CRAN Submission
For preparing and submitting packages to CRAN (pre-flight checks, extra CRAN
checks, pkgdown verification, rhub cross-platform checks, revdep checks,
submission, post-acceptance), use the cran-submission skill (/cran-submission).
Checklist
Before commit: