Metaprogramming with rlang

· 6 min read · Updated March 12, 2026 · advanced
r metaprogramming rlang tidy-evaluation

Metaprogramming is the art of writing code that manipulates code. In R, this power is particularly relevant when building packages that interface with the tidyverse or when creating domain-specific languages (DSLs). The rlang package provides the foundational primitives for tidy evaluation, making it the backbone of modern R metaprogramming.

This guide covers the core concepts you need to build sophisticated reactive interfaces, custom dplyr verbs, or embedded DSLs.

Understanding Tidy Evaluation

Tidy evaluation (TQE) is a framework for resolving symbols lazily within a data context. Unlike base R’s immediate evaluation, TQE defers resolution so users can pass unquoted column names that get evaluated in the correct environment.

The Bang-Bang Operator !!

The !! (bang-bang) operator is your primary tool for unquoting. It forces immediate evaluation of a single expression within a quoting context.

library(rlang)

# A simple function using tidy evaluation
filter_by_column <- function(data, col_name, value) {
  col_expr <- ensym(col_name)  # Convert string/symbol to expression
  data |> 
    dplyr::filter(!!col_expr == value)
}

# Usage
mtcars |> filter_by_column("cyl", 4)
mtcars |> filter_by_column(cyl, 4)  # Works with bare column name too

The key insight: ensym() captures the user’s input as a symbol, then !! injects it into the dplyr pipeline where it’s evaluated against the data frame.

Unquoting Splicing with !!!

When you have a list of expressions to inject, !!! splices them in:

# Building a complex filter programmatically
filter_conditions <- list(
  quote(cyl == 6),
  quote(disp > 200)
)

# Splicing multiple conditions
mtcars |> dplyr::filter(!!!filter_conditions)

# Practical example: dynamic column selection
select_args <- c("mpg", "cyl", "disp") |> rlang::syms()
mtcars |> dplyr::select(!!!select_args)

The Definition Operator :=

The := operator lets you use LHS definitions in contexts where = wouldn’t work, particularly useful when building calls programmatically:

# Creating new columns with dynamic names
create_column <- function(data, new_name, values) {
  data |> 
    dplyr::mutate(!!new_name := values)
}

mtcars |> create_column("log_mpg", log(mpg))

# Building column definitions on the fly
col_defs <- list(
  mpg_sq = quote(mpg^2),
  cyl_factor = quote(factor(cyl))
)
mtcars |> dplyr::mutate(!!!col_defs)

Quosures: Capturing Expressions with Their Environments

A quosure is a pair: an expression plus its environment. This is crucial because R code doesn’t just contain operations—it contains closures that reference variables from their creation context.

Capturing Quosures with enquo()

The enquo() function captures both the expression and its environment:

calculate_something <- function(x) {
  captured <- enquo(x)
  print(quosure_expr(captured))
  print(quosure_env(captured))
  captured
}

# When called with a variable from another environment
my_var <- 10
calculate_something(my_var + 5)
# Expression: my_var + 5
# Environment: <environment: R_GlobalEnv>

This matters when your function needs to pass the captured expression to another function that will evaluate it later—perhaps in a different environment (like inside a dplyr pipeline).

Working with Quosures

# Extracting components
capture_expr <- function(x) {
  q <- enquo(x)
  expr <- quo_get_expr(q)
  env <- quo_get_env(q)
  
  list(expr = expr, env = env)
}

# Building new expressions from quosures
build_expr <- function(x, op = "+") {
  x <- enquo(x)
  new_expr <- call(op, quo_get_expr(x), 1)
  quo(new_expr)
}

# Using the modified quosure
build_expr(mpg)
# Returns: <quosure> mpg + 1

Symbols and Expressions

Converting Between Strings and Symbols

The sym() family handles bidirectional conversion:

# String to symbol
sym("mpg")
#> mpg

# Symbol to string
sym_name(quote(mpg))
#> "mpg"

# Multiple at once
syms(c("mpg", "cyl", "disp"))
#> [[1]]
#> <symbol: mpg>
#> 
#> [[2]]
#> <symbol: cyl>
#> 
#> [[3]]
#> <symbol: disp>

Building Calls Programmatically

The call() function constructs unevaluated function calls:

# Building a call from pieces
call("filter", quote(mtcars), quote(cyl == 4))
# Returns: filter(mtcars, cyl == 4)

# With interpolated values
col <- sym("cyl")
value <- 6
call("filter", quote(mtcars), call("==", col, value))
# Returns: filter(mtcars, cyl == 6)

# Evaluating the constructed call
eval(call("filter", quote(mtcars), quote(cyl == 4)))

Building a Simple DSL

Domain-specific languages let you create expressive mini-languages for specific problems. Let’s build a simple filter DSL to demonstrate rlang’s power:

library(rlang)
library(dplyr)

# Define our DSL grammar
filter_dsl <- function(.data, ...) {
  conditions <- quos(...)
  
  for (i in seq_along(conditions)) {
    cond <- conditions[[i]]
    .data <- dplyr::filter(.data, !!cond)
  }
  
  .data
}

# Use our DSL
mtcars |> 
  filter_dsl(cyl == 6, disp > 150)

A More Complex DSL: Formula-Based Transformations

# DSL for transforming columns with formulas
transform_dsl <- function(.data, ...) {
  transformations <- quos(...)
  
  for (t in transformations) {
    formula <- quo_get_expr(t)
    # formula is something like: log(mpg)
    # Extract the target column name
    target <- rlang::f_lhs(formula)
    value <- rlang::f_rhs(formula)
    
    .data <- dplyr::mutate(.data, !!target := !!value)
  }
  
  .data
}

mtcars |>
  transform_dsl(
    log_mpg ~ log(mpg),
    mpg_doubled ~ mpg * 2
  )

Quoting and Evaluating in Your DSL

The real power emerges when you control evaluation explicitly:

# DSL with custom operators
`%in_dsl%` <- function(lhs, rhs) {
  # Capture both sides with their environments
  lhs <- enquo(lhs)
  rhs <- enquo(rhs)
  
  # Build a quosure that will evaluate correctly
  quo(!!quo_get_expr(lhs) %in% !!quo_get_expr(rhs))
}

# Usage in a filter context
mtcars |>
  filter(cyl %in_dsl% c(4, 6))

Practical Patterns

Defusing and Re-evaluating

Sometimes you need to capture an expression without evaluating it, then evaluate it later in a different context:

# Defuse an expression
defuse_expr <- function(x) {
  #quo_splice(enquo(x))  # Alternative approach
  expr <- enexpr(x)
  env <- current_env()
  new_quosure(expr, env)
}

# Re-evaluate with different data
recompute <- function(quosure, new_data) {
  # Create a new environment with new_data attached
  eval_tidy(quosure_expr(quosure), data = new_data)
}

Working with Dots ...

The ... argument requires special handling:

library(purrr)

handle_dots <- function(...) {
  # Capture all arguments as quosures
  dots <- quos(...)
  
  # Each element is a quosure
  purrr::map(dots, function(q) {
    list(
      expr = quo_get_expr(q),
      env = quo_get_env(q)
    )
  })
}

handle_dots(mpg, cyl + 1, "literal")

Error Handling with Typing

Validate inputs early to provide clear errors:

validate_column <- function(x, data) {
  x <- enquo(x)
  col_name <- quo_name(x)
  
  if (!col_name %in% names(data)) {
    abort(paste0("Column '", col_name, "' not found in data"))
  }
  
  x
}

# Safe usage
safe_filter <- function(data, col) {
  col <- validate_column({{col}}, data)
  dplyr::filter(data, !!col > median(!!col))
}

Common Pitfalls

  1. Forgetting !!: Passing a captured symbol without unquoting just passes the symbol as data, not as a column reference.

  2. Environment mismatches: Quosures carry their environment. If you build expressions in one context and evaluate in another, variables may resolve differently than expected.

  3. Non-standard evaluation in base R: Functions like subset() use non-standard evaluation that differs from tidy evaluation. Be explicit about which framework you’re using.

  4. Lazy evaluation traps: In functions with multiple arguments evaluated in different contexts, enquo() captures at the right moment—after the user evaluates their argument but before your function evaluates it.

See Also