rlang metaprogramming for tidy evaluation and non-standard evaluation (NSE) in R. Use when building data-masking APIs, wrapping dplyr/ggplot2/tidyr functions with {{ !! !!! operators, implementing quosures and dynamic dots, or designing tidyverse-style DSLs—e.g., "tidy eval wrapper function", "embrace operator usage", "NSE programming patterns", "custom select helpers".
Install with Tessl CLI
npx tessl i github:jjjermiah/dotagents --skill r-rlang-programming94
Does it follow best practices?
Validation for skill structure
Provide production-grade rlang metaprogramming guidance for R packages and functions that manipulate code, names, environments, conditions, or evaluation.
Before proceeding: Announce which rlang concepts you're using: "I'm using rlang for [tidy eval / error handling / code construction / environments]". This maintains clarity as we work through metaprogramming together.
Code is data. Defuse (capture) it, manipulate it, inject (insert) it elsewhere. You MUST understand defusal before using tidy eval: every {{ !! !!! operation depends on it.
# Defuse user argument
my_summarise <- function(data, var) {
dplyr::summarise(data, mean = mean({{ var }}))
}
# {{ defuses and injects in one step
mtcars %>% my_summarise(mpg)Key operators:
{{ - Embrace (defuse + inject)!! - Inject single expression!!! - Inject list of expressions (splice)enquo() - Defuse function argumentexpr() - Defuse local expressionReference: references/defusal-injection.md for complete defusal/injection patterns
Programming with data frames where columns are variables:
# Data-masking: columns as variables
with(mtcars, mean(cyl + am))
# Wrapping requires injection
mean_by <- function(data, by, var) {
data %>%
dplyr::group_by({{ by }}) %>%
dplyr::summarise(avg = mean({{ var }}))
}Key concepts:
{{ for passing arguments through.data$col and .env$var for disambiguation"{name}" := value and "mean_{{ var }}"Reference: references/tidy-evaluation.md for complete tidy eval patterns
Better errors, warnings, and messages:
check_positive <- function(x, arg = caller_arg(x), call = caller_env()) {
if (x < 0) {
cli::cli_abort(
c(
"{.arg {arg}} must be positive",
"x" = "You provided {x}"
),
call = call
)
}
}
my_function <- function(value) {
check_positive(value)
}
my_function(-5)
#> Error in `my_function()`:
#> ! `value` must be positive
#> ✖ You provided -5Key functions:
abort() - Structured errors with bullet listswarn() / inform() - Warnings and messagescaller_env() - Show user's function in errorcaller_arg() - Get user's argument nametry_fetch() - Modern error catching with chainingReference: references/conditions-errors.md for complete error handling
Explicit scoping and evaluation control:
# Create evaluation context
eval_context <- function(data, code) {
ctx <- new_environment(data, parent = caller_env())
eval_tidy(code, env = ctx)
}
# Use
data <- list(x = 1:10, y = 11:20)
eval_context(data, quo(mean(x + y)))Key functions:
env() - Create environmentenv_bind() / env_get() - Manage bindingscaller_env() / current_env() - Stack navigationlocal_bindings() - Temporary changesReference: references/environments.md for environment manipulation
Programmatic code construction:
# Build expressions from data
col_name <- "mpg"
condition <- call2(">", sym(col_name), 20)
# Inject into dplyr
mtcars %>% dplyr::filter(!!condition)
# Build multiple columns
cols <- syms(c("mpg", "cyl", "hp"))
mtcars %>% dplyr::select(!!!cols)Key functions:
sym() / syms() - String to symbolcall2() - Build function callsexpr() / exprs() - Create expressionscall_modify() - Modify call argumentsReference: references/symbols-calls.md for code construction
Robust argument handling:
my_function <- function(method = c("fast", "accurate"), ...) {
# Validate enumeration
method <- arg_match(method)
# No unexpected arguments
check_dots_empty()
# Continue with validated inputs
}Key functions:
arg_match() - Validate against allowed valuescheck_dots_empty() / check_dots_used() - Validate ...list2() - Collect dynamic dots with injectioncaller_arg() - Get user's argument nameReference: references/function-arguments.md for argument patterns
Always use embracing ({{) when wrapping dplyr/ggplot2 functions. No exceptions.
# Single variable
my_filter <- function(data, condition) {
dplyr::filter(data, {{ condition }})
}
# Multiple variables with ...
my_select <- function(data, ...) {
dplyr::select(data, ...)
}
# Named output with name injection
summarise_var <- function(data, var) {
dplyr::summarise(data, "mean_{{ var }}" := mean({{ var }}))
}You MUST include call = caller_env() in every error helper. Errors showing the helper instead of the user's function = failed user experience. Every time.
# Standard pattern for all validation functions
check_type <- function(x,
type,
arg = caller_arg(x),
call = caller_env()) {
if (!inherits(x, type)) {
cli::cli_abort(
"{.arg {arg}} must be a {.cls {type}}",
call = call,
class = "my_package_type_error"
)
}
}
# Set call once for whole function
my_function <- function(x, y) {
local_error_call(current_env())
if (x < 0) abort("x must be positive")
if (y < 0) abort("y must be positive")
# Both show correct error call
}# Build filter from user input
build_filter <- function(col, op, value) {
call2(op, sym(col), value)
}
filter_expr <- build_filter("age", ">", 18)
dplyr::filter(df, !!filter_expr)
# Combine multiple conditions
conditions <- list(
expr(age > 18),
expr(status == "active")
)
# Combine with & (using base Reduce instead of purrr)
combined <- Reduce(function(x, y) call2("&", x, y), conditions)
dplyr::filter(df, !!combined)# Capture multiple arguments
my_group_summarise <- function(data, ..., var) {
# ... goes to group_by unchanged
# var needs embracing for summarise
data %>%
dplyr::group_by(...) %>%
dplyr::summarise(mean = mean({{ var }}))
}
mtcars %>% my_group_summarise(cyl, am, var = mpg)# Optional grouping
summarise_optional_group <- function(data, var, by = NULL) {
if (!missing(by)) {
data <- dplyr::group_by(data, {{ by }})
}
dplyr::summarise(data, mean = mean({{ var }}))
}# WRONG - looks for variable named "var"
my_fn <- function(data, var) {
dplyr::filter(data, var > 10)
}
# RIGHT - injects user's expression
my_fn <- function(data, var) {
dplyr::filter(data, {{ var }} > 10)
}# WRONG - shows check_positive() in error
check_positive <- function(x) {
if (x < 0) abort("Must be positive")
}
# RIGHT - shows user's function
check_positive <- function(x, call = caller_env()) {
if (x < 0) abort("Must be positive", call = call)
}= with Computed Names# WRONG - creates column named "name"
name <- "result"
dplyr::mutate(df, name = value)
# RIGHT - uses := for computed names
dplyr::mutate(df, "{name}" := value)
dplyr::mutate(df, !!name := value)# WRONG - enquo() already defuses, don't use {{ too
my_fn <- function(data, var) {
var_quo <- enquo(var)
dplyr::filter(data, {{ var_quo }} > 10)
}
# RIGHT - either use enquo + !!, or just {{
my_fn <- function(data, var) {
var_quo <- enquo(var)
dplyr::filter(data, !!var_quo > 10)
}
# OR (simpler)
my_fn <- function(data, var) {
dplyr::filter(data, {{ var }} > 10)
}test_that("function handles bare names", {
result <- my_summarise(mtcars, mpg)
expect_equal(result$mean, mean(mtcars$mpg))
})
test_that("function handles expressions", {
result <- my_summarise(mtcars, mpg + cyl)
expect_equal(result$mean, mean(mtcars$mpg + mtcars$cyl))
})
test_that("error shows correct call", {
expect_snapshot(error = TRUE, {
my_function(-5)
})
})
test_that("error has correct class", {
expect_error(
my_function(invalid),
class = "my_pkg_type_error"
)
})| rlang | Base R | Why rlang? |
|---|---|---|
enquo(x) | substitute(x) | Quosures capture environment |
!! | bquote(.(x)) | Consistent syntax |
!!! | do.call() | Inline splicing |
abort() | stop() | Structured messages, classes, chaining |
eval_tidy() | eval() | Data mask + quosure support |
env() | new.env() | Cleaner API |
caller_env() | parent.frame() | Explicit intent |
Does your function accept bare column names for data-masking?
{{, load references/tidy-evaluation.mdAre you building/manipulating R expressions programmatically?
expr(), call2(), !!, load references/symbols-calls.mdDo you need custom evaluation contexts or environment control?
env(), eval_tidy(), load references/environments.mdAre you implementing structured error handling for a package?
abort(), caller_env(), load references/conditions-errors.mdDo you need dynamic dots with splicing/injection?
list2(), !!!, load references/function-arguments.md{{ when wrapping data-masking functions. Always. No exceptions.call = caller_env() is mandatory in every helper. Errors must show the user's call, never the helper's.stop() in production code.enquo(). Defusals without environments fail in complex pipelines.abort() are required, not optional.Tidy eval:
{{ - Defuse and inject function argumentenquo() / enquos() - Defuse arguments manually!! / !!! - Inject expressions.data$col / .env$var - DisambiguateErrors:
abort() - Throw structured errorcaller_env() - Get caller's environment for call contextcaller_arg() - Get caller's argument namelocal_error_call() - Set call context onceCode construction:
sym() / syms() - String to symbol(s)call2() - Build function callexpr() / exprs() - Create expression(s)Arguments:
arg_match() - Validate enumerationlist2() - Collect dynamic dotscheck_dots_empty() - No extra argumentsEnvironments:
env() - Create environmentenv_bind() / env_get() - Manage bindingscurrent_env() / caller_env() - Stack navigation{{, !!, !!! operatorsabort()arg_match(), list2(), or validating argumentsEach reference contains detailed patterns, examples, and edge cases.
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.