Functional Programming with purrr

Reviewed & published by Brayan K

purrr is the tidyverse's functional-programming toolkit: it lets you apply a function to every element of a list or vector with map(), replacing hand-written loops with short, type-safe, composable pipelines.

Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll learn map() and its typed variants, the ~ .x formula shorthand, parallel iteration with map2()/pmap(), folding with reduce(), and filtering with keep()/discard() — and how they compare to the base apply family.

What You'll Learn in This Lesson

1️⃣ map() and the Typed Variants

The core verb is map(): give it a list or vector and a function, and it runs the function on each element, returning a list of the same length. You can pass a function by name, write one inline, or use the formula shorthand.

# map() applies a function to EVERY element of a list or vector and
# always returns a LIST — one result per input element.
library(purrr)

nums <- list(1, 2, 3, 4)

map(nums, function(x) x^2)
# [[1]] 1   [[2]] 4   [[3]] 9   [[4]] 16   (a list of 4)

# Pass an existing function by name — no wrapper needed:
map(c(1, 4, 9, 16), sqrt)
# A list: 1, 2, 3, 4

A plain map() always returns a list, which is awkward if you wanted a numeric vector. The typed variants return an atomic vector of exactly that type — and error loudly if a result doesn't fit, catching bugs early.

# A bare map() always returns a list. The TYPED variants return an
# atomic vector and ERROR if the result isn't that type — a built-in
# safety check that base sapply() doesn't give you.
library(purrr)

map_dbl(c(1, 4, 9), sqrt)        # -> numeric vector  1 2 3
map_chr(c(1, 2, 3), ~ paste0("n", .x))   # -> character "n1" "n2" "n3"
map_lgl(c(2, 5, 8), ~ .x > 4)    # -> logical FALSE TRUE TRUE
map_int(1:3, ~ .x * 2L)          # -> integer 2 4 6

# Because map_dbl() guarantees a double vector, you can feed its result
# straight into sum(), mean(), or a tibble column with no surprises.

2️⃣ The ~ .x Shorthand & Parallel Maps

For short transformations, the ~ formula is the idiomatic purrr style: .x stands for the current element. It's just a compact way to write an anonymous function.

# The ~ formula is purrr shorthand for an anonymous function.
# Inside it, .x is the current element (and .y the second in map2).
library(purrr)

# These three lines are equivalent:
map_dbl(1:3, function(x) x * 10)
map_dbl(1:3, ~ .x * 10)            # ~ + .x  (most idiomatic)
map_dbl(1:3, \(x) x * 10)          # R 4.1+ native lambda

# .x reads naturally for one-liners:
prices <- c(10, 25, 40)
map_chr(prices, ~ paste0("$", .x))   # "$10" "$25" "$40"

When you need to walk two or more vectors in lockstep, reach for map2() (exactly two, with .x and .y) or pmap() (any number, passed as a list).

# map2() walks TWO vectors in parallel; pmap() walks ANY number,
# passed as a list of equal-length vectors.
library(purrr)

qty   <- c(2, 3, 5)
price <- c(10, 20, 4)

map2_dbl(qty, price, ~ .x * .y)   # element-wise product: 20 60 20

# pmap takes a list; name the pieces and use them by name (..1, ..2,
# or named args matching a function's parameters):
params <- list(
  base = c(100, 200),
  rate = c(0.1, 0.2)
)
pmap_dbl(params, function(base, rate) base * (1 + rate))   # 110 240

3️⃣ reduce(), keep() & discard()

Not every job is one-to-one. reduce() folds a whole list into a single value, and keep()/discard() filter a list by a predicate. Together with map(), they let you express most iteration as one readable pipe.

# reduce() collapses a list to a SINGLE value by applying a 2-arg
# function repeatedly, carrying the running result forward.
library(purrr)

reduce(1:5, `+`)            # 1+2+3+4+5 = 15
reduce(c(4, 9, 1, 7), max)  # running max -> 9
reduce(list(c(1,2), c(2,3), c(3,4)), union)   # combine sets

# keep()/discard() filter a list by a predicate (like Filter):
keep(1:10, ~ .x %% 2 == 0)    # even numbers: 2 4 6 8 10
discard(1:10, ~ .x %% 2 == 0) # the odds:     1 3 5 7 9

# compose nicely in a pipe:
1:10 |> keep(~ .x > 3) |> map_dbl(~ .x^2) |> reduce(`+`)

Notice the final pipe: keep then map_dbl then reduce. Each verb does one thing, and the data flows left to right — far easier to read and modify than a nested for loop.

Your turn. Fill in the ___ blank and run it.

# 🎯 YOUR TURN — replace each ___ then run it.
library(purrr)

words <- c("apple", "kiwi", "banana", "fig")

# 1) Use map_int() with nchar to get each word's length.
lengths <- map_int(words, ___)   # 👉 replace ___ with nchar
print(lengths)            # 5 4 6 3

# 2) Keep only the words longer than 4 letters.
long_words <- keep(words, ~ nchar(.x) > 4)
print(long_words)         # "apple" "banana"

# 3) Use reduce() with paste to join all words into one string,
#    separated by " - ".
joined <- reduce(words, ~ paste(.x, .y, sep = " - "))
print(joined)             # "apple - kiwi - banana - fig"

Mini-Challenge: Per-Group Means

This is the everyday purrr task: summarising a list of vectors without a single loop. Combine map_dbl(), map_int(), keep(), and reduce().

# 🎯 MINI-CHALLENGE: per-group means without a loop.
#
# You have sales for three regions stored as a named list of vectors:
#   sales <- list(east = c(10, 20, 30),
#                 west = c(5, 15),
#                 north = c(100, 50, 25, 25))
#
# 1. Use map_dbl() to get the MEAN of each region in one call.
# 2. Use map_int() to count how many months each region has (length).
# 3. Use keep() to keep only the regions whose mean is above 20.
# 4. Bonus: reduce() the original list with c() to flatten every
#    number into one big vector, then take its overall mean.
#
# Write it yourself, then run in RStudio or rdrr.io/snippets.

# your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — purrr

TaskR Syntax
Map → listmap(x, f)
Map → numericmap_dbl(x, ~ .x * 2)
Two inputsmap2_dbl(x, y, ~ .x + .y)
Many inputspmap_dbl(list(a, b), f)
Fold to one valuereduce(x, `+`)
Filter a listkeep(x, ~ .x > 0)

🎉 Lesson Complete!

Practice quiz

What does a bare map() always return?

  • A numeric vector
  • A list, one element per input
  • A data frame
  • A single value

Answer: A list, one element per input. map() always returns a list of the same length as its input.

Which variant guarantees a double (numeric) vector or errors?

  • map_dbl()
  • map()
  • map_chr()
  • map_lgl()

Answer: map_dbl(). map_dbl() returns a double vector and errors if a result is not a single double.

In a ~ formula, what does .x refer to?

  • The output list
  • The function name
  • The current element being processed
  • The index position

Answer: The current element being processed. .x is the current element inside purrr's formula shorthand.

Which function walks TWO vectors in parallel?

  • pmap()
  • map()
  • reduce()
  • map2()

Answer: map2(). map2() iterates over exactly two vectors, exposing .x and .y.

What does reduce(1:5, `+`) return?

  • 15
  • A list of 5 sums
  • 120
  • 5

Answer: 15. reduce() folds with + : 1+2+3+4+5 = 15.

Which helper keeps only elements matching a predicate?

  • discard()
  • keep()
  • map_lgl()
  • filter()

Answer: keep(). keep() retains elements for which the predicate is TRUE; discard() removes them.

Which typed variant returns a character vector?

  • map_int()
  • map_dbl()
  • map_chr()
  • map_lgl()

Answer: map_chr(). map_chr() returns a character vector, erroring on a type mismatch.

How do map() and reduce() differ?

  • map() folds to one value; reduce() is one-to-one
  • map() is one-to-one; reduce() folds many into one value
  • They are identical
  • reduce() only works on numbers

Answer: map() is one-to-one; reduce() folds many into one value. map() transforms each element independently; reduce() collapses the list to a single value.

Why are typed map_*() variants safer than base sapply()?

  • They run faster
  • They print nicer output
  • They promise an exact type or error, never silently guess
  • They avoid loading packages

Answer: They promise an exact type or error, never silently guess. sapply() guesses its return type; the typed map variants guarantee one or fail loudly.

How do you access the value of a reactive-style purrr formula's second input in map2()?

  • .z
  • .x only
  • .y
  • ..2 only

Answer: .y. In map2(), .x is the first vector's element and .y the second's.

Continue this course

Frequently asked questions

How is purrr's map() different from base sapply() and lapply()?

lapply() is closest to map(): both apply a function to each element and return a list. The big wins with purrr are consistency and safety. map() always returns a list; the typed variants (map_dbl, map_chr, ...) always return that exact atomic type or error — whereas sapply() guesses and can silently hand you a list, a vector, or a matrix depending on the data, which causes subtle bugs. purrr also adds the ~ .x shorthand, map2/pmap, and predicate helpers like keep/discard that base R spreads across several differently-named functions.

When should I use map_dbl vs map vs map_chr?

Pick the variant that matches the type you expect back. Use map() when each result is itself complex (a vector, data frame, or model) so a list is the right container. Use map_dbl() when every result is a single number, map_chr() for a single string, map_lgl() for TRUE/FALSE, and map_int() for whole numbers. The typed versions are preferable whenever they apply because the error you get on a type mismatch catches mistakes immediately instead of downstream.

What exactly do .x and .y mean in ~ formulas?

They're the auto-supplied argument names inside purrr's formula shorthand. In map(x, ~ ...), .x is the current element. In map2(x, y, ~ ...), .x is from the first vector and .y from the second. In pmap(), the positional arguments are ..1, ..2, ..3, or you can name a regular function's parameters. The formula form (~ .x * 2) is just a terse way to write function(x) x * 2; for anything longer than a line, a named function or the \(x) lambda is clearer.

What's the difference between map() and reduce()?

map() is one-to-one: n inputs produce n outputs, transforming each element independently. reduce() is many-to-one: it folds the whole list into a single value by repeatedly applying a two-argument function, carrying the accumulated result forward (sum, running max, merging data frames, unioning sets). A common pattern is to map() a transformation across elements and then reduce() the results together — for example map() each CSV to a data frame, then reduce() with bind_rows() to stack them.