---
name: r-tidyverse-style
source: https://app.decimal.ai/s/r-tidyverse-style@1/SKILL.md
source_sha256: 6008b170cdcb
---

# Modern tidyverse R style

## Contract

Enforces the modern-tidyverse form of R. Apply to every R snippet, function, or
script you write. Each rule below overrides a base-R or legacy-tidyverse default
that the base model reaches for by reflex; emit the conforming form instead.

## Rules

1. **Assignment** — use `<-`. Reserve `=` strictly for passing function
   arguments (`mean(x, na.rm = TRUE)`). Never assign a top-level object with `=`.
2. **Pipe** — use the native `|>`. Never use the magrittr `%>%`, and do not
   `library(magrittr)` to get it.
3. **Grouped summaries** — compute per-group statistics with the `.by =`
   argument inside `summarise()` or `mutate()`. Never write `group_by()` +
   `summarise()` + `ungroup()`. Multiple keys go in `.by = c(a, b)`.
4. **Embracing column arguments** — when a function takes a bare (unquoted)
   column name and forwards it to dplyr, embrace it with `{{ col }}`. Never pass
   the column as a string, and never use `substitute()`, `deparse()`,
   `df[[col]]`, `eval()`, or `aes_string()`.
5. **Iteration** — use type-stable purrr: `map_dbl()`, `map_chr()`,
   `map_lgl()`, `map_int()`. Never use `sapply()` or `vapply()`, and never bare
   `map()` when the output type is known. Use bare `map()` only when each result
   is itself a list/data frame.
6. **Errors** — raise with `cli::cli_abort()`. Never `stop()`. Pass a character
   vector `c(...)`: the FIRST element states the problem, the remaining elements
   are NAMED bullets — `x =` for the failure detail, `i =` for a hint/fix. Use
   inline markup: `{.arg name}` (argument), `{.fn name}` (function),
   `{.cls {class(x)}}` (class), `{.val {x}}` (value), `{?s}` (pluralise).
7. **File I/O** — read and write CSVs with readr `read_csv()` / `write_csv()`.
   Never base `read.csv()` / `write.csv()`.
8. **Strings** — use stringr: `str_to_lower()`, `str_to_upper()`,
   `str_detect()`, `str_replace_all()`, `str_remove_all()`, `str_extract()`,
   `str_trim()`, `str_squish()`. Never base `tolower()`, `toupper()`, `grepl()`,
   `gsub()`, `sub()`, `regmatches()`, `trimws()`, `substr()` for these jobs.
9. **Logicals** — spell `TRUE` / `FALSE` in full. Never `T` / `F` (they are
   reassignable variables, not constants).
10. **Optional arguments** — default optional filter bounds and flags to `NULL`
    and guard with `if (!is.null(x))`. Never default a bound to `NA`, `-Inf`,
    `Inf`, or a sentinel number.
11. **Function signatures** — put the primary data frame FIRST so calls pipe
    cleanly (`data |> f(...)`). Prefix optional/meta arguments with `.` (`.by`,
    `.data`) to avoid clashing with columns captured by `...`.
12. **Names** — snake_case for variables and functions; functions are verbs
    (`calculate_mean`), variables are nouns (`user_data`); no dots in names
    except S3 methods (`print.myclass`).
13. **Indentation** — 2 spaces, never tabs. One argument per line for long
    calls; one verb per line in a pipe chain.

## Worked examples

Each pair shows the base model's wrong default (BEFORE) and the conforming form
(AFTER).

**Rule 1 — assignment**
```r
# BEFORE
total = sum(x)
# AFTER
total <- sum(x)
```

**Rule 2 — native pipe**
```r
# BEFORE
sales %>% filter(region == "west") %>% summarise(n = n())
# AFTER
sales |> filter(region == "west") |> summarise(n = n())
```

**Rule 3 — .by grouping**
```r
# BEFORE
sales |>
  group_by(region) |>
  summarise(avg = mean(revenue)) |>
  ungroup()
# AFTER
sales |>
  summarise(avg = mean(revenue), .by = region)
```

**Rule 4 — embracing**
```r
# BEFORE
filter_above <- function(data, column, threshold) {
  data[data[[column]] > threshold, ]
}
# AFTER
filter_above <- function(data, column, threshold) {
  data |> filter({{ column }} > threshold)
}
```

**Rule 5 — type-stable iteration**
```r
# BEFORE
r2 <- sapply(models, function(m) summary(m)$r.squared)
# AFTER
r2 <- map_dbl(models, \(m) summary(m)$r.squared)
```

**Rule 6 — cli errors**
```r
# BEFORE
validate_input <- function(x) {
  if (!is.numeric(x)) stop("x must be numeric, not ", typeof(x))
}
# AFTER
validate_input <- function(x) {
  if (!is.numeric(x)) {
    cli::cli_abort(c(
      "{.arg x} must be numeric.",
      x = "You supplied {.cls {class(x)}}.",
      i = "Convert with {.fn as.numeric} first."
    ))
  }
}
```

**Rule 7 — readr I/O**
```r
# BEFORE
df <- read.csv("data/input.csv")
write.csv(out, "data/output.csv")
# AFTER
df <- read_csv("data/input.csv")
write_csv(out, "data/output.csv")
```

**Rule 8 — stringr**
```r
# BEFORE
people$name <- trimws(tolower(people$name))
df <- df[grepl("@stanford.edu", df$email), ]
# AFTER
people |> mutate(name = str_trim(str_to_lower(name)))
df |> filter(str_detect(email, "@stanford.edu"))
```

**Rule 9 — full logicals**
```r
# BEFORE
read_csv(path, show_col_types = F)
# AFTER
read_csv(path, show_col_types = FALSE)
```

**Rule 10 — NULL-guarded options**
```r
# BEFORE
range_filter <- function(df, min_value = -Inf, max_value = Inf) {
  df |> filter(value >= min_value, value <= max_value)
}
# AFTER
range_filter <- function(df, min_value = NULL, max_value = NULL) {
  if (!is.null(min_value)) df <- df |> filter(value >= min_value)
  if (!is.null(max_value)) df <- df |> filter(value <= max_value)
  df
}
```

**Rule 11 — data-first signature**
```r
# BEFORE
my_transform <- function(threshold, data) { ... }
# AFTER
my_transform <- function(data, threshold = 0.5) { ... }
```

**Rule 12 — names**
```r
# BEFORE
CalculateMean <- function(DataFrame) { ... }   # camel/Pascal, noun verb
# AFTER
calculate_mean <- function(data) { ... }       # snake_case, verb
```

## Edge cases & exceptions

- **`=` is correct inside calls.** `summarise(avg = mean(x))` and
  `f(na.rm = TRUE)` keep `=` — the `<-` rule is only for object assignment.
- **Bare `map()` is correct** when each element is a list/data frame/model
  (`map(paths, read_csv)` returns a list of tibbles). Only switch to `map_*()`
  when the per-element result is a single atomic value.
- **Single grouping key needs no `c()`.** `.by = region`, but
  `.by = c(store, month)` for two or more.
- **`%>%` is acceptable only** if the code must run on R < 4.1 (no native pipe).
  Assume a modern R; emit `|>`.
- **Dots in names are allowed for S3 methods** (`summary.lm`, `print.tbl`) and
  for the conventional tidyverse meta-args (`.by`, `.data`, `.before`). Do not
  use dots as a word separator elsewhere.
- **`cli::cli_abort()` first element is unnamed** (the headline); only the
  follow-up bullets are named `x =` / `i =` / `!=`. Putting a name on the first
  element silently turns it into a bullet with no headline.
- **Early `return()` is fine** for guard clauses (`if (is.null(x)) return(NULL)`);
  prefer implicit return of the last expression for the normal path.

## Do / Don't

- Never `library(magrittr)` for `%>%`; always use the built-in `|>`.
- Never `group_by() |> ... |> ungroup()`; always `.by =` inside the verb.
- Never `df[[col]]` / a string column; always embrace with `{{ col }}`.
- Never `sapply()`; always the typed `map_dbl/chr/lgl/int()`.
- Never `stop("...")`; always `cli::cli_abort(c(headline, x = ..., i = ...))`.
- Never `read.csv()`/`write.csv()`; always `read_csv()`/`write_csv()`.
- Never `tolower()`/`grepl()`/`gsub()`/`trimws()`; always the `str_*()` form.
- Never `T`/`F`; always `TRUE`/`FALSE`.
- Never default a bound to `NA`/`Inf`; always `NULL` + `if (!is.null())`.
- Never put the data frame after other args; always data-first.

## Common mistakes

- Reaching for `%>%` out of habit — it is the single most common base default.
- Writing the `group_by() + summarise() + ungroup()` triple instead of `.by =`.
- Accepting a column name as a string and indexing with `[[ ]]`, instead of
  embracing the bare name with `{{ }}`.
- Using `sapply()` (whose return type is unpredictable) instead of `map_dbl()`.
- Calling `stop()` with `paste()`-style string concatenation rather than a
  structured `cli::cli_abort()` bullet list.
- Mixing base string/IO functions (`gsub`, `read.csv`) into otherwise-tidy code.
- Abbreviating `TRUE`/`FALSE` to `T`/`F`.
- Defaulting optional bounds to `-Inf`/`Inf`/`NA` instead of `NULL`.

## Quick checklist

`<-` not `=` · `|>` not `%>%` · `.by =` not `group_by()` · `{{ }}` not strings ·
`map_dbl/chr/lgl/int()` not `sapply()` · `cli::cli_abort(c(...))` not `stop()` ·
`read_csv/write_csv` not base · `str_*()` not base · `TRUE/FALSE` not `T/F` ·
`NULL` + `if (!is.null())` for options · data-first signature · snake_case ·
2-space indent.
