Mastering the Ellipsis (...) in R Pipelines

When building modular data analysis pipelines in R, keeping your function signatures clean and maintainable is a major priority. Rather than defining dozens of explicit parameters in your wrapper or main functions, you can leverage R's dynamic ellipsis argument (..., known as the "dots" argument). However, passing optional arguments across nested or conditional sub-functions often leads to unexpected bugs, such as attempt to use zero-length variable name or unused argument errors.

In this guide, we'll explain why your current approach encounters errors and walk through idiomatic, robust patterns to handle additional arguments in R pipelines.

Why Did the Error Occur?

In the original script, calling the sub-function looked like this:

sub_function_1(main_arg, ... = ...)

In R, the syntax ... = ... assigns the ellipsis to an explicitly named argument called .... When inside sub_function_2, the call evaluates without standard named arguments, making ...names() return empty strings (or ""), which causes the assign() function to throw:

Error in assign(x = ...names()[i], value = ...elt(i)) : attempt to use zero-length variable name

Additionally, manually parsing the dots using assign() inside loops creates variable scoping issues and makes your code brittle. To pass dots downward, you simply write ....

Pattern 1: The Idiomatic List-Extraction Approach

Instead of manually populating the local environment using assign(), the standard R pattern is to capture ... into a named list with list(...) and extract optional parameters using the %||% (null-coalescing) operator or is.null() checks with sensible defaults.

# Helper: returns default if argument is NULL
`%||%` <- function(x, y) if (is.null(x)) y else x

sub_function_1 <- function(main_arg, ...) {
  dots <- list(...)
  optional_arg_1 <- dots$optional_arg_1 %||% 0
  
  print(main_arg + optional_arg_1)
}

sub_function_2 <- function(main_arg, ...) {
  dots <- list(...)
  optional_arg_2 <- dots$optional_arg_2 %||% 10
  
  print(main_arg + optional_arg_2)
}

sub_function_3 <- function(main_arg, ...) {
  dots <- list(...)
  optional_arg_3 <- dots$optional_arg_3 %||% 100
  
  print(main_arg + optional_arg_3)
}

Now your main wrapper simply passes ... down without any equal signs:

main_function <- function(main_arg, 
                          conditional1 = TRUE, 
                          conditional2 = TRUE, 
                          conditional3 = TRUE, 
                          ...) {
  if (conditional1) {
    sub_function_1(main_arg = main_arg, ...)
  }
  if (conditional2) {
    sub_function_2(main_arg = main_arg, ...)
  }
  if (conditional3) {
    sub_function_3(main_arg = main_arg, ...)
  }
}

Let's run the pipeline:

main_function(
  main_arg = 1,
  optional_arg_1 = 3,
  optional_arg_2 = 2,
  optional_arg_3 = 4
)
# Output:
# [1] 4
# [1] 3
# [1] 5

Pattern 2: Filtering Arguments with do.call()

If your sub-functions explicitly define their parameters rather than using ..., calling them with unneeded extra arguments can trigger unused argument errors. You can selectively forward only the arguments matching each sub-function's signature using formals() and do.call():

sub_function_1 <- function(main_arg, optional_arg_1 = 0) {
  main_arg + optional_arg_1
}

call_with_dots <- function(fun, ...) {
  args <- list(...)
  # Find which arguments the target function actually expects
  fun_args <- names(formals(fun))
  
  # Match by name if the target function does not have a '...'
  if (!"..." %in% fun_args) {
    args <- args[names(args) %in% fun_args]
  }
  
  do.call(fun, args)
}

# Usage inside the pipeline
main_function <- function(main_arg, conditional1 = TRUE, ...) {
  if (conditional1) {
    result <- call_with_dots(sub_function_1, main_arg = main_arg, ...)
    print(result)
  }
}

Summary & Best Practices

  • Never pass ... = ...: Simply forward the ellipsis using func(...).
  • Avoid assign() for arguments: Capture list(...) to access values safely by name without polluting the environment.
  • Provide fallback defaults: Use dots$param %||% default_val to handle missing parameters gracefully.
  • Use do.call() for strict APIs: When downstream functions do not permit arbitrary extra arguments, filter the argument list against names(formals(fun)).