| name | R Code Patterns for Packages |
| description | Critical rules and patterns for writing R code in packages, including forbidden functions, namespace management, and package state handling |
R Code Patterns for Packages
Overview
Writing code for R packages differs significantly from writing R scripts. This skill covers the critical rules, forbidden functions, and best practices that prevent common package development errors.
CRITICAL: Forbidden Functions in R/
These functions should NEVER appear in your package's R/ code:
NEVER Use library() or require()
Why: These modify the user's search path and create dependencies on load order.
my_function <- function(x) {
library(dplyr)
x %>% filter(value > 0)
}
my_function <- function(x) {
dplyr::filter(x, value > 0)
}
my_function <- function(x) {
filter(x, value > 0)
}
Exception: require() is acceptable for Suggests packages, but use requireNamespace() instead:
if (require("ggplot2")) {
}
if (requireNamespace("ggplot2", quietly = TRUE)) {
}
check_suggested <- function(pkg) {
if (!requireNamespace(pkg, quietly = TRUE)) {
stop(
"Package '", pkg, "' required but not installed.\n",
"Install with: install.packages('", pkg, "')",
call. = FALSE
)
}
}
NEVER Use source()
Why: Package code is loaded via the namespace mechanism, not sourcing.
source("R/utils.R")
NEVER Use ::: (Triple Colon)
Why: Accessing internal functions from other packages is fragile and violates encapsulation.
result <- dplyr:::some_internal_function(x)
Exception: Acceptable in tests to test your own internal functions:
test_that("internal function works", {
expect_equal(mypackage:::.internal_function(5), 10)
})
Namespace Management
Default Pattern: pkg::fun()
For packages listed in Imports:, use explicit namespace references:
my_function <- function(x) {
dplyr::mutate(x, new_col = value * 2)
}
Advantages:
- Clear where functions come from
- No NAMESPACE imports needed for simple usage
- Easier to understand code provenance
Import Pattern: @importFrom
For heavily-used functions, import to your namespace:
my_function <- function(x) {
x %>%
filter(.data$value > 0) %>%
mutate(doubled = .data$value * 2) %>%
select(.data$id, .data$doubled)
}
When to import:
- Functions used in many places (reduces typing)
- Infix operators (%>%, %||%, :=)
- Functions from your package's core dependencies
- Performance-critical code (tiny speedup from avoiding :::)
Full Import: @import (Rare)
Import an entire package namespace (almost never recommended):
Only use for:
- rlang (if building a tidyverse-style package)
- Your own package's internal utilities package
Why avoid:
- Imports ALL exported functions (namespace pollution)
- Risk of function name conflicts
- Makes code provenance unclear
- Can break if imported package adds conflicting exports
R Code Restrictions
Use TRUE/FALSE, Never T/F
Why: T and F are variables that can be reassigned; TRUE and FALSE are reserved words.
if (x == T) {
return(F)
}
if (x == TRUE) {
return(FALSE)
}
T <- FALSE
x == T
ASCII-Only in .R Files
Why: R CMD check enforces ASCII in package R code for portability.
message("Processing data → results")
city <- "São Paulo"
message("Processing data -> results")
city <- "S\u00e3o Paulo"
Handling Unicode:
"\u00e9"
"\u2192"
"\u2264"
iconv("São Paulo", to = "ASCII//TRANSLIT")
Global State and Options
CRITICAL: Never modify global state without restoration.
my_function <- function() {
options(stringsAsFactors = FALSE)
}
my_function <- function() {
old_opts <- options(stringsAsFactors = FALSE)
on.exit(options(old_opts), add = TRUE)
}
my_function <- function() {
withr::local_options(stringsAsFactors = FALSE)
}
Common global state to be careful with:
options()
par() (graphics parameters)
- Working directory (
setwd())
- Random seed (
set.seed())
- Environment variables
plot_something <- function() {
old_par <- par(mfrow = c(2, 2))
on.exit(par(old_par), add = TRUE)
}
plot_something <- function() {
withr::local_par(mfrow = c(2, 2))
}
Build-Time vs Load-Time Code
Understanding Execution Timing
CRITICAL: Top-level code in R/ files executes during R CMD build, not when users load your package!
message("Building package")
current_year <- as.integer(format(Sys.Date(), "%Y"))
.onLoad <- function(libname, pkgname) {
message("Loading package")
}
my_function <- function() {
year <- as.integer(format(Sys.Date(), "%Y"))
}
What Belongs Where
PACKAGE_VERSION <- "1.0.0"
API_ENDPOINT <- "https://api.example.com"
USER_HOME <- Sys.getenv("HOME")
N_CORES <- parallel::detectCores()
IS_WINDOWS <- .Platform$OS.type == "windows"
get_user_home <- function() {
Sys.getenv("HOME")
}
get_n_cores <- function() {
parallel::detectCores()
}
is_windows <- function() {
.Platform$OS.type == "windows"
}
Package State Management
Using Environments for State
Packages should not use global variables. Use environments instead:
pkg_env <- new.env(parent = emptyenv())
.onLoad <- function(libname, pkgname) {
pkg_env$cache <- list()
pkg_env$config <- list(
api_key = NULL,
verbose = FALSE
)
}
get_config <- function(key) {
pkg_env$config[[key]]
}
set_config <- function(key, value) {
pkg_env$config[[key]] <- value
invisible(value)
}
cache_get <- function(key) {
pkg_env$cache[[key]]
}
cache_set <- function(key, value) {
pkg_env$cache[[key]] <- value
invisible(value)
}
Why parent = emptyenv():
- Prevents accidental variable lookup in global environment
- Keeps package environment isolated
- Makes dependencies explicit
.onLoad() and .onAttach()
Place these in R/zzz.R (alphabetically last, so other code is already sourced):
.onLoad <- function(libname, pkgname) {
pkg_env$cache <- new.env(parent = emptyenv())
op <- options()
op_mypackage <- list(
mypackage.verbose = FALSE,
mypackage.api_url = "https://api.example.com"
)
toset <- !(names(op_mypackage) %in% names(op))
if (any(toset)) options(op_mypackage[toset])
invisible()
}
.onAttach <- function(libname, pkgname) {
packageStartupMessage(
"Welcome to mypackage version ",
utils::packageVersion("mypackage")
)
if (is.null(get_api_key())) {
packageStartupMessage(
"No API key found. Set one with set_api_key()"
)
}
invisible()
}
.onUnload <- function(libpath) {
library.dynam.unload("mypackage", libpath)
}
Key differences:
.onLoad(): Always runs (even with loadNamespace()), no messages
.onAttach(): Only with library(), messages OK
- Use
.onLoad() for setup, .onAttach() for user communication
File Organization
Anti-Pattern: One Function Per File
Problem: Creates too many small files, hard to navigate.
# AVOID (unless functions are very large):
R/
├── add.R
├── subtract.R
├── multiply.R
├── divide.R
├── mean.R
├── median.R
└── mode.R
Anti-Pattern: All Code in One File
Problem: Creates one huge file, hard to maintain.
# AVOID:
R/
└── functions.R # 5000 lines!
Good Pattern: Logical Grouping
R/
├── mypackage-package.R # Package docs and imports
├── arithmetic.R # add(), subtract(), multiply(), divide()
├── statistics.R # mean(), median(), mode(), sd()
├── data.R # Data documentation
├── utils.R # Internal utilities
└── zzz.R # .onLoad, .onAttach
Grouping strategies:
- By feature/domain
- By class (if using S3/R6)
- By workflow step
- utils.R for shared internal functions
File Naming Conventions
R/
├── aaa-imports.R # Loaded first (package-level imports)
├── class-model.R # Class definition
├── model-methods.R # Methods for model class
├── model-utils.R # Utilities for model class
├── generics.R # Generic function definitions
├── import-standalone-*.R # Standalone utilities
├── utils.R # General utilities
├── utils-assertions.R # Assertion utilities
├── data.R # Data documentation
├── mypackage-package.R # Package documentation
└── zzz.R # .onLoad, .onAttach (loaded last)
Handling Non-Standard Evaluation
data.table and dplyr Column References
Problem: R CMD check warns about "no visible binding for global variable".
my_function <- function(data) {
data %>%
dplyr::filter(value > 0) %>%
dplyr::mutate(doubled = value * 2)
}
Solution 1: Use .data pronoun (tidyverse)
my_function <- function(data) {
data %>%
dplyr::filter(.data$value > 0) %>%
dplyr::mutate(doubled = .data$value * 2)
}
Solution 2: Use globalVariables()
utils::globalVariables(c("value", "doubled"))
my_function <- function(data) {
data %>%
dplyr::filter(value > 0) %>%
dplyr::mutate(doubled = value * 2)
}
Solution 3: Use .SD (data.table)
my_function <- function(dt) {
dt[, .(doubled = .SD$value * 2)]
}
Declaring Global Variables
utils::globalVariables(c(
"value", "doubled", "id", "name",
".",
"where"
))
Performance Considerations
Vectorization
sum_slow <- function(x) {
total <- 0
for (i in seq_along(x)) {
total <- total + x[i]
}
total
}
sum_fast <- function(x) {
sum(x)
}
Pre-allocation
result <- c()
for (i in 1:10000) {
result <- c(result, i^2)
}
result <- numeric(10000)
for (i in 1:10000) {
result[i] <- i^2
}
result <- (1:10000)^2
Avoid Repeated Function Calls
for (i in 1:length(x)) {
}
n <- length(x)
for (i in 1:n) {
}
for (i in seq_along(x)) {
}
Common Pitfalls
1. Using library() in Package Code
Problem: Most common mistake by newcomers.
my_plot <- function(data) {
library(ggplot2)
ggplot(data, aes(x, y)) + geom_point()
}
my_plot <- function(data) {
ggplot(data, aes(x, y)) + geom_point()
}
2. Forgetting to Restore Options
Problem: User's session left in modified state.
my_function <- function() {
options(warn = -1)
}
my_function <- function() {
withr::local_options(warn = -1)
}
3. System Queries at Top Level
Problem: Captures build-time values, not runtime.
N_CORES <- parallel::detectCores()
my_parallel <- function() {
cl <- parallel::makeCluster(N_CORES)
}
my_parallel <- function(n_cores = parallel::detectCores()) {
cl <- parallel::makeCluster(n_cores)
}
4. Using T/F Instead of TRUE/FALSE
if (verbose == T) {
message("Details...")
}
if (verbose == TRUE) {
message("Details...")
}
if (isTRUE(verbose)) {
message("Details...")
}
5. Not Using parent = emptyenv()
Problem: Package environment can accidentally access globals.
cache <- new.env()
cache <- new.env(parent = emptyenv())
6. Direct UTF-8 in Code
Problem: R CMD check fails on ASCII-enforcing systems.
cities <- c("São Paulo", "Zürich")
cities <- c("S\u00e3o Paulo", "Z\u00fcrich")
7. Using ::: for Other Packages
result <- otherpkg:::internal_function(x)
8. Namespace Pollution with @import
9. Missing globalVariables() Declaration
Problem: R CMD check warnings about NSE variables.
summarize_data <- function(df) {
df %>%
group_by(category) %>%
summarize(mean_value = mean(value))
}
utils::globalVariables(c("category", "value", "mean_value"))
10. Using source() for Code Organization
source("R/utils.R")
Quick Reference
Package Code Checklist
- Use pkg::fun() for Imports
- Use requireNamespace() for Suggests
- Use TRUE/FALSE (never T/F)
- Use ASCII in .R files (\uXXXX for Unicode)
- Restore global state with on.exit() or withr
- Use new.env(parent = emptyenv()) for state
- Put .onLoad() in R/zzz.R
- Use utils::globalVariables() for NSE
- library() or require() (except requireNamespace() for Suggests)
- source()
- ::: to access other packages' internals
- T/F instead of TRUE/FALSE
- UTF-8 directly in .R files
- Modify options/par without restoration
- System queries at top level
- One function per file (usually)
- All code in one file
Template: R/zzz.R
pkg_env <- new.env(parent = emptyenv())
.onLoad <- function(libname, pkgname) {
pkg_env$cache <- new.env(parent = emptyenv())
op <- options()
op_mypackage <- list(
mypackage.verbose = FALSE
)
toset <- !(names(op_mypackage) %in% names(op))
if (any(toset)) options(op_mypackage[toset])
invisible()
}
.onAttach <- function(libname, pkgname) {
packageStartupMessage("Welcome to mypackage")
invisible()
}
Resources