Skip to content

gcol33/restrictR

Repository files navigation

restrictR

the same input check, written once

CRAN status CRAN downloads Monthly downloads R-CMD-check Codecov test coverage License: MIT

Composable runtime contracts for R, built from base pipe chains into immutable validator closures.

Write each input contract once. restrictR turns a |> chain of small require_*() steps into a callable validator you drop at the top of any function. Every |> returns a new validator, so you branch from a shared base without touching it, and every failure comes back in one structured path: message format. No DSL, no operator overloading; a validator is an ordinary R closure you can pass around, print, and read back as documentation.

library(restrictR)

# define once
require_positive_scalar <- restrict("x") |>
  require_numeric(no_na = TRUE) |>
  require_length(1L) |>
  require_between(lower = 0, exclusive_lower = TRUE)

# enforce anywhere
require_positive_scalar(3.14)   # passes silently
require_positive_scalar(-1)     # Error: x: must be in (0, Inf]
                                #   Found: -1

One contract, not scattered guard clauses

Every exported function tends to start with the same if (!is.numeric(...)) stop(...) checks, copied across methods and drifting apart, until one function says "x must be numeric" and another says "expected numeric input". Define the contract once as a pipe chain, call it at the top of any method, and the rule lives in one place with one error format. Change the rule, change it once.

require_newdata <- restrict("newdata") |>
  require_df() |>
  require_has_cols(c("x1", "x2")) |>
  require_col_numeric("x1", no_na = TRUE, finite = TRUE) |>
  require_col_numeric("x2", no_na = TRUE, finite = TRUE) |>
  require_nrow_min(1L)

predict2 <- function(object, newdata, ...) {
  require_newdata(newdata)
  predict(object, newdata = newdata)
}

Rules that depend on other arguments

A one-sided formula references another argument by name. Context is passed explicitly when you call the validator, so evaluation never reaches into parent frames for it.

require_pred <- restrict("pred") |>
  require_numeric(no_na = TRUE) |>
  require_length_matches(~ nrow(newdata))

require_pred(predictions, newdata = df)

Path-aware error messages

Failures report the exact path and position, in one shared format:

newdata$x2: must be numeric, got character

pred: length must match nrow(newdata) (100)
  Found: length 50

x: must not contain NA
  At: 2, 5, 9

A validator that documents itself

The same step list that runs the checks also prints the contract and renders it as text for roxygen, so @param documentation and enforcement stay in sync:

print(require_newdata)
#> <restriction newdata>
#>   1. must be a data.frame
#>   2. must have columns: "x1", "x2"
#>   3. $x1 must be numeric (no NA, finite)
#>   4. $x2 must be numeric (no NA, finite)
#>   5. must have at least 1 row

#' @param newdata `r as_contract_text(require_newdata)`

Custom steps

When a contract needs a domain-specific invariant, require_custom() runs your own check while keeping the same error format through fail():

require_weights <- restrict("weights") |>
  require_numeric(no_na = TRUE) |>
  require_between(lower = 0, upper = 1) |>
  require_custom(
    label = "must sum to 1",
    fn = function(value, name, ctx) {
      if (abs(sum(value) - 1) > 1e-8) {
        fail(name, "must sum to 1", found = sprintf("sum = %g", sum(value)))
      }
    }
  )

Built-in steps

Category Steps
Type checks require_df(), require_numeric(), require_integer(), require_character(), require_logical()
Null / missingness require_not_null(), require_no_na(), require_finite()
Structure require_scalar(), require_named(), require_length(), require_length_min(), require_length_max(), require_length_matches(), require_nrow_min(), require_nrow_matches(), require_has_cols()
Values require_positive(), require_negative(), require_between(), require_one_of(), require_unique()
Columns require_col_numeric(), require_col_character(), require_col_between(), require_col_one_of()
Extension require_custom()

Comparison with checkmate

checkmate is a fast, C-backed toolkit for argument checking. It offers assert*, check*, test*, and expect* families so that, inside a function, you write one call per argument: assertNumeric(x, lower = 0, len = 1) either passes or raises an error on the spot. Checks are expressed as direct function calls and run where they are written.

restrictR works one level up. Instead of calling checks inline, you build a named validator once as a |> chain of require_*() steps and reuse that object across every function that takes the same argument. The two packages differ along a few axes:

  • Reuse. A restrictR validator is a callable closure you define once and call at the top of many functions, so the rule for newdata lives in one place. checkmate checks are written inline at each call site.
  • Composition. Validators compose with the base pipe and branch immutably: base |> require_length(1L) and base |> require_between(lower = 0) share a base without modifying it. checkmate composes by listing several assert* calls in sequence.
  • Self-documentation. A validator prints its own contract, and as_contract_text() renders it for a roxygen @param, so enforcement and documentation stay in sync.
  • Dependent rules. Cross-argument constraints use one-sided formulas (require_length_matches(~ nrow(newdata))) with context passed explicitly at call time. checkmate expresses such relationships with ordinary R between the checks.
  • Dependencies. checkmate uses compiled C code for speed. restrictR is pure base R with zero runtime dependencies.

If you want fast, inline per-argument assertions, checkmate is a mature and well-tested choice. If you want to name a contract once, reuse it across functions, and have it document itself, that is what restrictR is built for. The two also coexist: a require_custom() step can call a checkmate assertion inside it.

Installation

install.packages("restrictR")            # CRAN

install.packages("pak")                  # development version
pak::pak("gcol33/restrictR")

Documentation

Support

"Software is like sex: it's better when it's free." -- Linus Torvalds

I'm a PhD student who builds R packages in my free time because I believe good tools should be free and open. I started these projects for my own work and figured others might find them useful too.

If this package saved you some time, buying me a coffee is a nice way to say thanks. It helps with my coffee addiction.

Buy Me A Coffee

License

MIT (see the LICENSE file)

Citation

@software{restrictR,
  author = {Colling, Gilles},
  title = {restrictR: Composable Runtime Contracts for R},
  year = {2026},
  url = {https://github.com/gcol33/restrictR}
}

About

Composable Runtime Contracts for R

Topics

Resources

License

Unknown, MIT licenses found

Licenses found

Unknown
LICENSE
MIT
LICENSE.md

Stars

Watchers

Forks

Packages

 
 
 

Contributors