Programming with dplyr and the tidyverse is incredibly powerful, but transitioning from interactive analysis to writing reusable functions can be tricky. A common challenge is handling the dot-dot-dot (...) parameter when you need to loop over individual variables or access them sequentially.

While the curly-curly operator {{ }} works beautifully for passing a single variable, it doesn't allow you to slice, index, or loop over multiple arguments passed via .... In this article, we will show you how to capture, inspect, and iterate over ... arguments using the rlang package.

The Solution: Capturing Arguments with rlang::enquos()

To loop over the arguments in ..., you need to capture them as a list of quosures (quoted expressions coupled with an environment). The rlang::enquos() function does exactly this.

Once captured in a list, you can loop through them using a standard for loop or functional programming tools like purrr::walk() or lapply().

Implementation Example

Here is how you can write the function to accept unquoted column names, loop over them, group by each column sequentially, and create a new mean column:

library(dplyr)
library(rlang)

myfunc <- function(data, ...) {
  # 1. Capture the dots as a list of quosures
  cols <- enquos(...)
  
  # 2. Loop through each captured column
  for (i in seq_along(cols)) {
    col_quosure <- cols[[i]]
    
    # Convert the quosure to a string to construct the new column name
    col_name <- as_name(col_quosure)
    new_col_name <- paste0(col_name, "_mean")
    
    # 3. Use bang-bang (!!) and the walrus operator (:=) to evaluate dynamically
    data <- data |>
      group_by(!!col_quosure) |>
      mutate(!!new_col_name := mean(!!col_quosure, na.rm = TRUE)) |>
      ungroup()
  }
  
  return(data)
}

You can now call this function exactly as desired, using unquoted column names:

# Test the function with the iris dataset
iris |> 
  myfunc(Sepal.Width, Sepal.Length) |> 
  head()

How It Works Under the Hood

  • enquos(...): This captures all the arguments passed to ... without evaluating them immediately. It returns a named list of quosures.
  • as_name(col_quosure): This converts a single quosure (like Sepal.Width) into a plain string ("Sepal.Width"). This is essential for programmatically creating new column names using paste0().
  • !! (Bang-Bang): The injection operator tells dplyr to evaluate the quosure immediately in the context of the data frame rather than looking for a literal variable named "col_quosure".
  • := (Walrus Operator): Standard R assignment (=) does not allow LHS (left-hand side) evaluation. The := operator allows you to use a dynamically generated string on the left side of your assignment.

Alternative: Converting Quosures to Strings First

If you prefer using dplyr's across() and tidyselect helpers like all_of(), you can convert the captured quosures into a character vector first. This makes the loop body cleaner and avoids using !! inside the verbs:

myfunc_tidyselect <- function(data, ...) {
  # Capture and convert all arguments to a character vector
  cols <- sapply(enquos(...), as_name)
  
  for (col in cols) {
    new_col_name <- paste0(col, "_mean")
    
    data <- data |>
      group_by(across(all_of(col))) |>
      mutate(!!new_col_name := mean(.data[[col]], na.rm = TRUE)) |>
      ungroup()
  }
  
  return(data)
}

Both approaches yield identical results, but the across(all_of()) method is often preferred in modern dplyr development as it aligns closely with tidyselect best practices.

` practices.