Skip to main content

How to use dplyr/tidyr from TypR

TypR does not require you to abandon the tidyverse. This guide shows how to use dplyr, tidyr, and related packages from typed code.

The core pattern: R {} blocks

Tidyverse functions rely heavily on non-standard evaluation (NSE), formulas, and the pipe operator %>% / |>. These are fundamentally untyped constructs. The idiomatic way to use them from TypR is R {} blocks:

let filtered <- R {
mtcars |>
dplyr::filter(cyl > 4) |>
dplyr::select(mpg, cyl, hp)
};

The R {} block captures its body verbatim and emits it as plain R code. It is not type-checked, but it integrates perfectly with typed code that surrounds it.

Declaring tidyverse signatures

For typed interop with dplyr, use @extern to declare the types of functions you want to call with type safety:

@extern dplyr::filter: (data: Foreign<Any>, ...) -> Foreign<Any>;
@extern dplyr::mutate: (data: Foreign<Any>, ...) -> Foreign<Any>>;
@extern dplyr::select: (data: Foreign<Any>, ...) -> Foreign<Any>>;
@extern dplyr::summarise: (data: Foreign<Any>, ...) -> Foreign<Any>>;
@extern dplyr::arrange: (data: Foreign<Any>, ...) -> Foreign<any>;

With these declarations, dplyr::filter(df, .data$x > 1) is partially checked — the compiler verifies the first argument is a dataframe, but ... is intentionally untyped to allow NSE.

Mixing typed and untyped code

The best pattern is to do data preparation in R {} blocks and business logic in typed functions:

@extern dplyr::filter: (data: Foreign<Any>, ...) -> Foreign<Any>;
@extern dplyr::mutate: (data: Foreign<Any>, ...) -> Foreign<Any>;

# Typed business logic
let compute_score <- fn(row: Foreign<Any>): num {
R { row$age * 0.3 + row$income * 0.7 }
};

# Untyped data preparation
let prepare <- fn(df: Foreign<Any>): Foreign<Any> {
R {
df |>
dplyr::filter(!is.na(age)) |>
dplyr::mutate(score = compute_score(.))
}
};

Using @importFrom

To generate proper roxygen2 import directives, use @importFrom:

@importFrom dplyr filter select mutate arrange summarise;
@importFrom tidyr pivot_longer pivot_wider;
@importFrom purrr map map_dbl;

This ensures your package's NAMESPACE file is correctly populated when the package is built.

Working with dataframes in TypR

TypR supports dataframe types for typed access:

type PersonRow <- df[1]{ name: char, age: int };

let process <- fn(df: PersonRow): char {
df$name
};

However, for most tidyverse workflows, Foreign<Any> with R {} blocks is more practical because tidyverse functions return generic tibbles, not specific dataframe types.

Practical patterns

Typed wrapper around dplyr

@extern dplyr::group_by: (data: Foreign<Any>, ...) -> Foreign<Any>>;
@extern dplyr::summarise: (data: Foreign<Any>, ...) -> Foreign<Any>>;

let summarize_by_group <- fn(
df: Foreign<Any>,
group_col: char
): Foreign<Any> {
R {
df |>
dplyr::group_by(.data[[group_col]]) |>
dplyr::summarise(n = dplyr::n())
}
};

Type-safe column access

@extern dplyr::pull: (data: Foreign<Any>, var: char) -> Foreign<Any>;

let get_column <- fn(df: Foreign<Any>, col: char): Foreign<Any> {
dplyr::pull(df, col)
};

Best practices

  1. Use R {} for NSE — Tidyverse NSE (bare column names, formulas, ...) cannot be type-checked. Embrace R {} blocks for these operations.

  2. Signature the entry points — Add @extern for dplyr/tidyr functions you call frequently. This gives you partial type safety on the first argument.

  3. Use @importFrom — Generate proper namespace imports for your package. This is required for CRAN and good practice for any R package.

  4. Keep business logic typed — Do data transformation in R {} blocks, but write the core logic of your package in typed TypR functions.

  5. Return Foreign<Any> — Tidyverse functions return tibbles, which are opaque R objects. Do not try to type them as specific dataframe types unless you know the exact schema.

Where to go next