| name | r-code |
| trigger | writing R functions / API design / error handling |
| description | Guide for writing R code. Use when writing new functions, designing APIs, or reviewing/modifying existing R code. |
R code
This skill covers how to design and write R functions — including naming conventions, signatures, API conventions, input validation, error handling, and common pitfalls. For documenting functions, use the document skill. For tests, use the tdd-workflow skill. Once satisfied with the function of the code (see the tdd-workflow skill), verify that the code passes R CMD Check without errors, warnings, or notes. Address any issues before finalizing the code.
Naming conventions
Functions
Functions use snake_case and should be verbs or verb phrases that describe what the function does:
fetch_records()
build_summary()
validate_input()
A function name should be descriptive enough to make its purpose clear without a comment. Prefer clarity over brevity — don't abbreviate unless there is a widely understood convention (e.g. df for data frame, dir for directory).
Internal helpers use a dot prefix:
.parse_response()
.validate_columns()
Parameters
Parameters use snake_case and should generally be nouns, occasionally adjectives. The same rule applies: clarity over brevity.
fetch_records(file_path, page_size, overwrite)
fetch_records(fp, ps, ow)
File organization
For existing code and capabilites that fit into the existing structure: Please use the currently available file structure and naming conventions. Do not create new .R files unless you are adding a completely new capability that does not fit into the existing structure. If you are adding a new capability, please follow the file organization guidelines below: One exported function per file: R/{function_name}.R (e.g. fetch_records() → R/fetch_records.R). Internal helpers used exclusively by that function live in the same file. Shared helpers go in R/utils.R or R/utils-{topic}.R (e.g. R/utils-parsing.R).
Coding style
- Always run
air format . after generating code.
- Use the base pipe operator (
|>) not the magrittr pipe (%>%).
- Use
\() ... for single-line anonymous functions. For all other cases, use function() {...}.
Function design
Functional core, imperative shell — pure, testable functions that accept data and return data form the core. The imperative shell orchestrates program flow, manages state, and calls the functional core.
Functions should be small and single-purpose. Each function should operate at a single level of abstraction: it either orchestrates calls to other functions, or performs a direct operation on data, but does not mix the two.
build_report <- function(data, output_path) {
data <- .clean_data(data)
summary <- .compute_summary(data)
.write_report(summary, output_path)
}
.clean_data <- function(data) {
data |>
dplyr::filter(!is.na(value)) |>
dplyr::mutate(value = round(value, 2))
}
Name functions well enough that their purpose is obvious from the call site. When reading the orchestrator above, each step is self-documenting — no comments needed.
Simplify control flow — prefer guard clauses and returning early over complex if/else structures.
Pure conditionals — the expression inside a conditional check should not cause side effects. Extract the pure check from the impure action into separate functions if needed.
General API design patterns
Enum-like arguments — declare choices as the default vector; resolve with rlang::arg_match() at the top of the function:
summarize_data <- function(x, method = c("mean", "median")) {
method <- rlang::arg_match(method)
}
NULL as "not provided" — use NULL as the default for optional arguments where there is no sensible scalar fallback; check with is.null():
fetch_records <- function(x, output_column = NULL) {
if (!is.null(output_column)) { ... }
}
S3 object construction — build as a named list, set class explicitly:
.new_summary <- function(values, method) {
out <- list(values = values, method = method)
class(out) <- c(paste0("summary_", method), "data_summary")
out
}
call propagation in internal validators — helpers that validate arguments and may throw errors should accept and forward call:
.check_non_empty <- function(x, call = rlang::caller_env()) {
if (length(x) == 0L) {
.pkg_abort("Input {.arg x} cannot be empty.", "empty_input", call = call)
}
}
process_data <- function(x, call = rlang::caller_env()) {
.check_non_empty(x, call = call)
...
}
Return tibbles, not data frames:
summarize_data <- function(x) {
result |> tibble::as_tibble()
}
Input validation
Validate all input parameters. Provide cli::cli_abort() errors with informative messages and appropriate error classes if parameter values are invalid. This ensures that users get clear feedback when they call the function incorrectly, and allows them to handle specific error classes if needed.
Some validation functions exist (i.e. to check the spectra format or intesnsity type). Use them when appropriate.
Validate in the function that uses the parameter, not in a caller that passes it through. This preserves R's lazy evaluation — if a parameter is never used on a code path, it is never evaluated or validated.
build_report <- function(data, title, page_size) {
data <- .clean_data(data)
summary <- .compute_summary(data, page_size)
.write_report(summary, title)
}
.compute_summary <- function(data, page_size, call = rlang::caller_env()) {
page_size <- stbl::to_int_scalar(page_size, call = call)
...
}
.write_report <- function(summary, title, call = rlang::caller_env()) {
title <- stbl::to_chr_scalar(title, call = call)
...
}
build_report <- function(data, title, page_size) {
title <- stbl::to_chr_scalar(title)
page_size <- stbl::to_int_scalar(page_size)
...
}
Internal vs. exported functions
Export a function when:
- Users will call it directly
- Other packages may want to extend it
- It is a stable, intentional part of the API
Keep a function internal when:
- It is an implementation detail that may change
- It is only used within the package
- Exporting it would clutter the user-facing API
Internal helpers use a dot prefix (e.g. .parse_response()).
Error handling
Always provide informative error messages with cli::cli_abort().
Provide both the function and cause of the error in the message.
Common package mistakes
library(dplyr)
dplyr::filter(...)
options(my_option = TRUE)
withr::local_options(list(my_option = TRUE))
read.csv("/home/user/data.csv")
system.file("extdata", "data.csv", package = "mypkg")
Dependencies
Use existing imports first
Packages already in Imports in DESCRIPTION should be preferred over base R equivalents: purrr::map() over lapply(), rlang::is_*() predicates over is.*(), and withr::local_*() over manual on.exit() state management.
When to add a new dependency
Add a dependency when it provides significant functionality that would be complex or brittle to reimplement — date parsing, web requests, complex string manipulation. Stick with base R or existing imports when the solution is straightforward.
Adding a new dependency requires explicit discussion with the developer.