R Currying & Partial Application: purrr::partial() & rlang
Partial application creates a new function from an existing one by locking in some arguments upfront, so you call the simpler version everywhere else. In R, purrr::partial() is the standard tool for this, and with rlang's quasiquotation you control exactly when each pre-filled argument gets evaluated.
What Is Partial Application and Why Does It Matter?
If you've ever written na.rm = TRUE for the hundredth time inside a pipeline, you've already felt the pain that partial application solves. Let's fix that right now.
Three calls, zero repeated arguments. partial(mean, na.rm = TRUE) returns a brand-new function that behaves exactly like mean() except na.rm is always TRUE. You pass only the data.
The manual alternative works too, but it's more ceremony for the same result:
Both approaches produce the same output. The difference is that partial() is declarative, you describe what to pre-fill rather than writing boilerplate.
mean_na tells the reader what it does. Writing function(x) mean(x, na.rm = TRUE) tells them how. Names beat implementation details.Try it: Create a partially applied function ex_round2 that rounds to 2 decimal places, then test it on pi.
Click to reveal solution
Explanation: partial(round, digits = 2) locks in digits so you only need to supply the number to round.
How Does purrr::partial() Work Under the Hood?
Understanding what partial() returns helps you debug and compose functions confidently. Let's create a simple partial function and inspect it.
When you print add5, R shows you the partially applied call: ` +(5, ...) . The 5 is baked in, and ...` accepts whatever you pass next.
Notice the ... signature, partial() always returns a function with ... as its arguments, regardless of the original function's signature. This is a deliberate design choice that lets partial() work with functions that use non-standard evaluation (like dplyr::filter() or ggplot2::aes()).
The trade-off is that you lose autocomplete for the remaining arguments. If you need autocomplete, write a manual wrapper with explicit argument names instead.
purrr::partial() is the standard choice.Try it: Create a ex_dash_paste function using partial(paste, sep = "-"), then call it with "R", "is", "fun".
Click to reveal solution
Explanation: sep is locked to "-", so every call joins its arguments with dashes.
When Should You Use Lazy vs Eager Evaluation?
By default, partial() evaluates pre-filled arguments lazily, they're re-evaluated every time you call the function. This is usually what you want, but sometimes you need to freeze a value at creation time. That's where rlang's !! (bang-bang) operator comes in.
Think of it this way: lazy evaluation is like checking the weather each morning before you dress. Eager evaluation is like setting your thermostat once when you move in.
Each call to f_lazy() draws a fresh Poisson random number for n, so the length changes. Now compare with eager evaluation:
With !!, the rpois(1, 5) call runs once, at the moment partial() executes, and the result (8) is baked into the function permanently.
Here's a practical scenario where the distinction matters. Suppose you want a function that stamps the current time onto a message:
The lazy version updates the timestamp each call, perfect for logging. But if you wanted to record when the session started, you'd use eager evaluation with !!Sys.time() to freeze the timestamp at creation time.
partial(rnorm, mean = my_var) and later change my_var, the partial function uses the new value. Use !! to lock in the current value when the function is created.Try it: Create a partial function ex_rnorm that generates 10 random normals with a mean of 100, using eager evaluation so the mean can't change later.
Click to reveal solution
Explanation: !!my_mean_val evaluates my_mean_val immediately and locks in 100. Even if you later set my_mean_val <- 999, the function still uses 100.
How Do You Control Where New Arguments Go With ...?
By default, pre-filled arguments go first and the caller's arguments come after. But some base R functions have the argument you want to pre-fill in the middle or at the end. The ... = syntax lets you control exactly where new arguments get inserted.
That works because paste() takes ..., all arguments just concatenate. But what if you need to insert new arguments between pre-filled ones?
The caller's arguments ("middle_1", "middle_2") slot in where ... = sits, between "start" and "end".
Here's a practical use case. grepl() takes pattern first and x second, but you might want to pre-fill the options while leaving both pattern and x free:
The ... = tells partial() that pattern and x (the first two positional arguments) come from the caller, and ignore.case and perl are locked in after them.
grepl(), sub(), and sprintf() where options sit at the end.Try it: Create a case-insensitive grep shortcut ex_igrep using partial(grep, ... = , ignore.case = TRUE, value = TRUE). Test it by searching for "the" in c("The End", "beginning", "THERE").
Click to reveal solution
Explanation: value = TRUE returns matching strings instead of indices, and ignore.case = TRUE makes the match case-insensitive. Both are locked in by partial().
What Is Currying and How Does It Differ From Partial Application?
You'll often see "currying" and "partial application" used interchangeably, but they're different techniques. Partial application fixes some arguments and returns a function that takes the rest. Currying transforms a function of N arguments into a chain of N single-argument functions.
In Haskell, every function is automatically curried, add 3 5 is actually (add 3) 5, where add 3 returns a function that adds 3. R doesn't do this automatically, but you can build it yourself.
curry_add(3) doesn't compute anything, it returns a new function that remembers a = 3 and waits for b. This is a closure (the returned function "closes over" the value of a).
You can generalise this into a helper that curries any function:
This works, but it's more of a learning exercise than production code. In practice, partial() covers 95% of use cases more cleanly.
Try it: Write a manually curried ex_multiply function that takes one argument and returns a function that multiplies by it. Test that ex_multiply(3)(7) returns 21.
Click to reveal solution
Explanation: ex_multiply(3) returns a closure that remembers a = 3. When you call that closure with 7, it computes 3 * 7 = 21.
Where Does Partial Application Shine in Real R Workflows?
Now that you understand the mechanics, let's see where partial() earns its keep in day-to-day R code. These patterns come up constantly.
Pattern 1: Cleaner map() pipelines. Instead of writing anonymous functions inside map(), pre-fill the fixed arguments:
Both produce the same result, but the partial() version names the operation, replace_space, making the pipeline self-documenting.
Pattern 2: Summarise helpers across columns. Build a family of NA-safe summary functions and use them with across():
Three lines of partial() replace six anonymous functions. Every analyst on your team can reuse mean_na without wondering about the na.rm flag.
map(x, \(item) fn(item, arg = val)), write map(x, partial(fn, arg = val)). It's shorter and more declarative.Try it: Use partial() to create ex_log10 that computes base-10 logarithms, then map it over the list list(1, 10, 100, 1000).
Click to reveal solution
Explanation: partial(log, base = 10) locks in the base, so each call only needs the number. map_dbl() applies it to each element and returns a numeric vector.
Practice Exercises
Exercise 1: Build a Logging Function Toolkit
Create two functions using partial() and paste():
my_log_info(msg)that prepends"[INFO]"and a timestampmy_log_error(msg)that prepends"[ERROR]"and a timestamp
Then use map_chr() to apply my_log_info to the vector c("model started", "data loaded", "training complete").
Click to reveal solution
Explanation: partial(paste, "[INFO]", Sys.time(), "-") pre-fills the prefix and timestamp. Because Sys.time() is lazily evaluated, each call gets the current time.
Exercise 2: Compose a Text Cleaning Pipeline
Use partial() to create specialised versions of string functions, then chain them with purrr::compose() to build a single my_clean_text() function. Apply it to c(" Hello WORLD!! ", " R is GREAT!! ").
Your pipeline should: (1) trim whitespace, (2) convert to lowercase, (3) remove all ! characters.
Click to reveal solution
Explanation: compose(remove_bangs, tolower, str_trim) creates a pipeline that first trims, then lowercases, then removes bangs. The partial() call locks the pattern and replacement into str_replace_all().
Exercise 3: Curried Power Function
Write a my_curry_power(exp) function that returns a single-argument function raising its input to the exp power. Use it to create my_square, my_cube, and my_fourth. Verify that map_dbl(1:5, my_square) returns c(1, 4, 9, 16, 25).
Click to reveal solution
Explanation: my_curry_power(2) returns a closure that remembers exp = 2. Each specialised function is a single-argument function that works seamlessly with map_dbl().
Putting It All Together
Let's build a reusable data-analysis helper toolkit with partial application and run a complete pipeline on the airquality dataset.
Without partial(), the across() call would need three anonymous functions: \(x) mean(x, na.rm = TRUE), \(x) sd(x, na.rm = TRUE), and \(x) median(x, na.rm = TRUE). The partial versions are shorter, reusable, and they give each operation a name the whole team understands.
Summary
| Concept | What It Does | R Tool |
|---|---|---|
| Partial application | Pre-fills some arguments, returns a simpler function | purrr::partial() |
| Lazy evaluation | Pre-filled args re-evaluated each call | Default behaviour |
| Eager evaluation | Pre-filled args fixed at creation | !! (bang-bang) |
... = positioning |
Controls where the caller's args go | partial(f, a, ... = , b) |
| Currying | Transforms N-arg function into chain of 1-arg functions | Manual closures |
| Best use case | map() pipelines, across() helpers, repeated config |
map(x, partial(fn, arg = val)) |
Key takeaway: reach for partial() whenever you catch yourself passing the same argument more than twice. It turns repetitive configuration into a named, reusable function, and that makes your code both shorter and easier to read.
References
- Wickham, H., Advanced R, 2nd Edition. Chapter 11: Function Operators. Link
- purrr documentation, partial() reference. Link
- Pedersen, T.L., curry package: Operator-based currying and partial application. Link
- Piccolo, A., "Delicious R Curry" (2015). Link
- R Core Team, R Language Definition, Section on Closures. Link
- Henry, L. & Wickham, H., rlang: quasiquotation. Link
- purrr vignette, Functional programming in other languages. Link
Continue Learning
- R Function Operators, The parent tutorial covering compose(), negate(), and more function operators including partial application.
- purrr map() Variants, Master map(), map2(), imap(), and pmap(), partial application's best friend for applying functions across lists and vectors.
- R Function Factories, Learn how functions that return functions (closures) relate to currying and when factories beat partial application.