Install any skill in seconds. Free to start, no credit card required.
Get Started Free →rlang metaprogramming patterns for data-masking, injection operators, and dynamic dots. Use when writing functions that use tidy evaluation.
.claude/skills/brycewang-stanford-rlang-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 16% | 0% |
| case-10 | ✓→✗ | ▼ Worse | 70% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 228% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 287% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 133% | 0% |
Metaprogramming framework that powers tidyverse data-masking
Data-masking allows R expressions to refer to data frame columns as if they were variables in the environment. rlang provides the metaprogramming framework that powers tidyverse data-masking.
{{}} - Forward function arguments to data-masking functions!! - Inject single expressions or values!!! - Inject multiple arguments from a list... with injection support.data/.env - Explicit disambiguation between data and environment variables{{}}Use {{}} to forward function arguments to data-masking functions:
r# Single argument forwarding my_summarise <- function(data, var) { data |> dplyr::summarise(mean = mean({{ var }})) } # Works with any data-masking expression mtcars |> my_summarise(cyl) mtcars |> my_summarise(cyl * am) mtcars |> my_summarise(.data$cyl) # pronoun syntax supported
... (No Special Syntax Needed)r# Simple dots forwarding my_group_by <- function(.data, ...) { .data |> dplyr::group_by(...) } # Works with tidy selections too my_select <- function(.data, ...) { .data |> dplyr::select(...) } # For single-argument tidy selections, wrap in c() my_pivot_longer <- function(.data, ...) { .data |> tidyr::pivot_longer(c(...)) }
.dataUse .data pronoun for programmatic column access:
r# Single column by name my_mean <- function(data, var) { data |> dplyr::summarise(mean = mean(.data[[var]])) } # Usage - completely insulated from data-masking mtcars |> my_mean("cyl") # No ambiguity, works like regular function # Multiple columns with all_of() my_select_vars <- function(data, vars) { data |> dplyr::select(all_of(vars)) } mtcars |> my_select_vars(c("cyl", "am"))
| Operator | Use Case | Example | |----------|----------|---------| | {{ }} | Forward function arguments | summarise(mean = mean({{ var }})) | | !! | Inject single expression/value | summarise(mean = mean(!!sym(var))) | | !!! | Inject multiple arguments | group_by(!!!syms(vars)) | | .data[[]] | Access columns by name | mean(.data[[var]]) |
!!r# Create symbols from strings var <- "cyl" mtcars |> dplyr::summarise(mean = mean(!!sym(var))) # Inject values to avoid name collisions df <- data.frame(x = 1:3) x <- 100 df |> dplyr::mutate(scaled = x / !!x) # Uses both data and env x # Use data_sym() for tidyeval contexts (more robust) mtcars |> dplyr::summarise(mean = mean(!!data_sym(var)))
!!!r# Multiple symbols from character vector vars <- c("cyl", "am") mtcars |> dplyr::group_by(!!!syms(vars)) # Or use data_syms() for tidy contexts mtcars |> dplyr::group_by(!!!data_syms(vars)) # Splice lists of arguments args <- list(na.rm = TRUE, trim = 0.1) mtcars |> dplyr::summarise(mean = mean(cyl, !!!args))
list2() for Dynamic Dots Supportrmy_function <- function(...) { # Collect with list2() instead of list() for dynamic features dots <- list2(...) # Process dots... } # Enables these features: my_function(a = 1, b = 2) # Normal usage my_function(!!!list(a = 1, b = 2)) # Splice a list my_function("{name}" := value) # Name injection my_function(a = 1, ) # Trailing commas OK
r# Basic name injection name <- "result" list2("{name}" := 1) # Creates list(result = 1) # In function arguments with {{ my_mean <- function(data, var) { data |> dplyr::summarise("mean_{{ var }}" := mean({{ var }})) } mtcars |> my_mean(cyl) # Creates column "mean_cyl" mtcars |> my_mean(cyl * am) # Creates column "mean_cyl * am" # Allow custom names with englue() my_mean <- function(data, var, name = englue("mean_{{ var }}")) { data |> dplyr::summarise("{name}" := mean({{ var }})) } # User can override default mtcars |> my_mean(cyl, name = "cylinder_mean")
.data and .env Best Practicesr# Explicit disambiguation prevents masking issues cyl <- 1000 # Environment variable mtcars |> dplyr::summarise( data_cyl = mean(.data$cyl), # Data frame column env_cyl = mean(.env$cyl), # Environment variable ambiguous = mean(cyl) # Could be either (usually data wins) ) # Use in loops and programmatic contexts vars <- c("cyl", "am") for (var in vars) { result <- mtcars |> dplyr::summarise(mean = mean(.data[[var]])) print(result) }
Converting between data-masking and tidy selection behaviors:
r# across() as selection-to-data-mask bridge my_group_by <- function(data, vars) { data |> dplyr::group_by(across({{ vars }})) } # Works with tidy selection mtcars |> my_group_by(starts_with("c")) # across(all_of()) as names-to-data-mask bridge my_group_by <- function(data, vars) { data |> dplyr::group_by(across(all_of(vars))) } mtcars |> my_group_by(c("cyl", "am"))
r# Transform single arguments by wrapping my_mean <- function(data, var) { data |> dplyr::summarise(mean = mean({{ var }}, na.rm = TRUE)) } # Transform dots with across() my_means <- function(data, ...) { data |> dplyr::summarise(across(c(...), ~ mean(.x, na.rm = TRUE))) } # Manual transformation (advanced) my_means_manual <- function(.data, ...) { vars <- enquos(..., .named = TRUE) vars <- purrr::map(vars, ~ expr(mean(!!.x, na.rm = TRUE))) .data |> dplyr::summarise(!!!vars) }
r# Avoid - String parsing and eval (security risk) var <- "cyl" code <- paste("mean(", var, ")") eval(parse(text = code)) # Dangerous! # Good - Symbol creation and injection !!sym(var) # Safe symbol injection # Avoid - get() in data mask (name collisions) with(mtcars, mean(get(var))) # Collision-prone # Good - Explicit injection or .data with(mtcars, mean(!!sym(var))) # Safe # or mtcars |> summarise(mean(.data[[var]])) # Even safer
r# Don't use {{ }} on non-arguments my_func <- function(x) { x <- force(x) # x is now a value, not an argument quo(mean({{ x }})) # Wrong! Captures value, not expression } # Don't mix injection styles unnecessarily # Pick one approach and stick with it: # Either: embrace pattern my_func <- function(data, var) data |> summarise(mean = mean({{ var }})) # Or: defuse-and-inject pattern my_func <- function(data, var) { var <- enquo(var) data |> summarise(mean = mean(!!var)) }
r# In DESCRIPTION: Imports: rlang # In NAMESPACE, import specific functions: importFrom(rlang, enquo, enquos, expr, !!!, :=) # Or import key functions: #' @importFrom rlang := enquo enquos
r#' @param var <[`data-masked`][dplyr::dplyr_data_masking]> Column to summarize #' @param ... <[`dynamic-dots`][rlang::dyn-dots]> Additional grouping variables #' @param cols <[`tidy-select`][dplyr::dplyr_tidy_select]> Columns to select
r# Test data-masking behavior test_that("function supports data masking", { result <- my_function(mtcars, cyl) expect_equal(names(result), "mean_cyl") # Test with expressions result2 <- my_function(mtcars, cyl * 2) expect_true("mean_cyl * 2" %in% names(result2)) }) # Test injection behavior test_that("function supports injection", { var <- "cyl" result <- my_function(mtcars, !!sym(var)) expect_true(nrow(result) > 0) })
This modern rlang approach enables clean, safe metaprogramming while maintaining the intuitive data-masking experience users expect from tidyverse functions.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 16,252 | 5,621 | -65% | 1 | 1 | 0% | 3,170 | 3,686 | +16% | 0 | 0 | — |
case-02 | pass→pass | 4,866 | 5,269 | +8% | 1 | 1 | 0% | 1,059 | 3,473 | +228% | 0 | 0 | — |
case-03 | pass→pass | 3,983 | 2,978 | -25% | 1 | 1 | 0% | 800 | 3,093 | +287% | 0 | 0 | — |
case-04 | pass→pass | 7,126 | 5,267 | -26% | 1 | 1 | 0% | 1,578 | 3,677 | +133% | 0 | 0 | — |
case-05 | pass→pass | 6,828 | 5,073 | -26% | 1 | 1 | 0% | 1,475 | 3,575 | +142% | 0 | 0 | — |
case-06 | pass→pass | 4,858 | 4,433 | -9% | 1 | 1 | 0% | 1,130 | 3,470 | +207% | 0 | 0 | — |
case-07 | pass→pass | 3,716 | 3,267 | -12% | 1 | 1 | 0% | 868 | 3,200 | +269% | 0 | 0 | — |
case-08 | pass→pass | 3,651 | 3,427 | -6% | 1 | 1 | 0% | 780 | 3,171 | +307% | 0 | 0 | — |
case-09 | pass→pass | 5,007 | 2,606 | -48% | 1 | 1 | 0% | 1,168 | 3,012 | +158% | 0 | 0 | — |
case-10 | pass→fail | 11,006 | 7,546 | -31% | 1 | 1 | 0% | 2,407 | 4,084 | +70% | 0 | 0 | — |
case-11 | pass→pass | 7,221 | 4,897 | -32% | 1 | 1 | 0% | 1,367 | 3,383 | +147% | 0 | 0 | — |
case-12 | pass→pass | 3,811 | 3,737 | -2% | 1 | 1 | 0% | 684 | 3,125 | +357% | 0 | 0 | — |
case-13 | pass→pass | 7,144 | 4,693 | -34% | 1 | 1 | 0% | 1,456 | 3,413 | +134% | 0 | 0 | — |
case-14 | pass→pass | 6,970 | 5,607 | -20% | 1 | 1 | 0% | 1,401 | 3,636 | +160% | 0 | 0 | — |
case-15 | pass→pass | 7,743 | 5,849 | -24% | 1 | 1 | 0% | 1,638 | 3,691 | +125% | 0 | 0 | — |
case-16 | pass→pass | 9,454 | 5,683 | -40% | 1 | 1 | 0% | 1,873 | 3,605 | +92% | 0 | 0 | — |
case-17 | pass→pass | 12,352 | 9,397 | -24% | 1 | 1 | 0% | 2,245 | 4,167 | +86% | 0 | 0 | — |
case-18 | pass→pass | 4,338 | 2,950 | -32% | 1 | 1 | 0% | 723 | 2,986 | +313% | 0 | 0 | — |
case-19 | pass→pass | 5,859 | 3,705 | -37% | 1 | 1 | 0% | 1,024 | 3,111 | +204% | 0 | 0 | — |
case-20 | pass→pass | 4,290 | 5,519 | +29% | 1 | 1 | 0% | 901 | 3,512 | +290% | 0 | 0 | — |
case-21 | pass→pass | 6,898 | 5,899 | -14% | 1 | 1 | 0% | 1,389 | 3,565 | +157% | 0 | 0 | — |
case-22 | pass→pass | 9,870 | 3,829 | -61% | 1 | 1 | 0% | 2,198 | 3,207 | +46% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of 0 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.