Author: Gilles Colling
Date: 2026-03-04

# Runtime Contracts for R Functions

#### Gilles Colling

#### 2026-03-04

    library(restrictR)

## Overview

`restrictR` lets you define reusable input contracts from small building blocks using the base pipe `|>`. A contract is defined once and called like a function to validate data at runtime.

Section | What you’ll learn
--- | ---
Reusable schemas | Define and reuse data.frame contracts
Dependent validation | Constraints that reference other arguments
Enum arguments | Restrict string arguments to a fixed set
Custom steps | Domain-specific invariants
Self-documentation | Print, `as_contract_text()`, `as_contract_block()`
Using contracts in packages | The recommended pattern for R packages

## Reusable Schemas

The most common use case: validating a `newdata` argument in a predict-like function. Instead of scattering `if`/`stop()` blocks, define the contract 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)

The result is a callable function. Valid input passes silently:

    good <- data.frame(x1 = c(1, 2, 3), x2 = c(4, 5, 6))
    require_newdata(good)

Invalid input produces a structured error with the exact path and position:

    require_newdata(42)
    #> Error:
    #> ! newdata: must be a data.frame, got numeric

    require_newdata(data.frame(x1 = c(1, NA), x2 = c(3, 4)))
    #> Error:
    #> ! newdata$x1: must not contain NA
    #>   At: 2

    require_newdata(data.frame(x1 = c(1, 2), x2 = c("a", "b")))
    #> Error:
    #> ! newdata$x2: must be numeric, got character

Every error follows the same format: `path: message`, optionally followed by `Found:` and `At:` lines. This makes errors instantly recognizable and grep-friendly.

## Dependent Validation

Some contracts depend on context. A prediction vector must have the same length as the rows in `newdata`:

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

The formula `~ nrow(newdata)` declares a dependency on `newdata`. Pass it explicitly when calling the validator:

    newdata <- data.frame(x1 = 1:5, x2 = 6:10)
    require_pred(c(0.1, 0.2, 0.3, 0.4, 0.5), newdata = newdata)

Mismatched lengths produce a precise diagnostic:

    require_pred(c(0.1, 0.2, 0.3), newdata = newdata)
    #> Error:
    #> ! pred: length must match nrow(newdata) (5)
    #>   Found: length 3

Missing context is caught before any checks run:

    require_pred(c(0.1, 0.2, 0.3))
    #> Error:
    #> ! `pred` depends on: newdata. Pass newdata = ... when calling the validator.

Context can also be passed as a named list via `.ctx`:

    require_pred(1:5, .ctx = list(newdata = newdata))

## Enum Arguments

For string arguments that must be one of a fixed set:

    require_method <- restrict("method") |>
      require_character(no_na = TRUE) |>
      require_length(1L) |>
      require_one_of(c("euclidean", "manhattan", "cosine"))

    require_method("euclidean")

    require_method("chebyshev")
    #> Error:
    #> ! method: must be one of ["euclidean", "manhattan", "cosine"]
    #>   Found: "chebyshev"
    #>   At: 1

## Custom Steps

For domain-specific invariants that don’t belong in the built-in set, use `require_custom()`. The step function receives `(value, name, ctx)` and should call `stop()` on failure:

    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) {
            stop(sprintf("%s: must sum to 1, sums to %g", name, sum(value)),
                 call. = FALSE)
          }
        }
      )

    require_weights(c(0.5, 0.3, 0.2))

    require_weights(c(0.5, 0.5, 0.5))
    #> Error:
    #> ! weights: must sum to 1, sums to 1.5

Custom steps can also declare dependencies:

    require_probs <- restrict("probs") |>
      require_numeric(no_na = TRUE) |>
      require_custom(
        label = "length must match number of classes",
        deps = "n_classes",
        fn = function(value, name, ctx) {
          if (length(value) != ctx$n_classes) {
            stop(sprintf("%s: expected %d probabilities, got %d",
                         name, ctx$n_classes, length(value)), call. = FALSE)
          }
        }
      )

    require_probs(c(0.3, 0.7), n_classes = 2L)

## Self-Documentation

Print a validator to see its full contract:

    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

Use `as_contract_text()` to generate a one-line summary for roxygen `@param`:

    as_contract_text(require_newdata)
    #> [1] "Must be a data.frame. must have columns: \"x1\", \"x2\". $x1 must be numeric (no NA, finite). $x2 must be numeric (no NA, finite). must have at least 1 row."

Use `as_contract_block()` for multi-line output suitable for `@details`:

    cat(as_contract_block(require_newdata))
    #> - must be a data.frame
    #> - must have columns: "x1", "x2"
    #> - $x1 must be numeric (no NA, finite)
    #> - $x2 must be numeric (no NA, finite)
    #> - must have at least 1 row

## Using Contracts in Packages

The recommended pattern: define contracts in `R/contracts.R`, call them at the top of exported functions.

    # R/contracts.R
    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_pred <- restrict("pred") |>
      require_numeric(no_na = TRUE, finite = TRUE) |>
      require_length_matches(~ nrow(newdata))

    # R/predict.R

    #' Predict from a fitted model
    #'
    #' @param newdata Must be a data.frame. must have columns: "x1", "x2". $x1 must be numeric (no NA, finite). $x2 must be numeric (no NA, finite). must have at least 1 row.
    #' @param ... additional arguments passed to the underlying model.
    #'
    #' @export
    my_predict <- function(object, newdata, ...) {
      require_newdata(newdata)
      pred <- do_prediction(object, newdata)
      require_pred(pred, newdata = newdata)
      pred
    }

Contracts compose naturally with the pipe and branch safely (each `|>` creates a new validator):

    base <- restrict("x") |> require_numeric()
    v1 <- base |> require_length(1L)
    v2 <- base |> require_between(lower = 0)

    # base is unchanged
    length(environment(base)$steps)
    #> [1] 1
    length(environment(v1)$steps)
    #> [1] 2
    length(environment(v2)$steps)
    #> [1] 2

    sessionInfo()
    #> R version 4.5.2 (2025-10-31 ucrt)
    #> Platform: x86_64-w64-mingw32/x64
    #> Running under: Windows 11 x64 (build 26200)
    #>
    #> Matrix products: default
    #>   LAPACK version 3.12.1
    #>
    #> locale:
    #> [1] LC_COLLATE=C
    #> [2] LC_CTYPE=English_United States.utf8
    #> [3] LC_MONETARY=English_United States.utf8
    #> [4] LC_NUMERIC=C
    #> [5] LC_TIME=English_United States.utf8
    #>
    #> time zone: Europe/Luxembourg
    #> tzcode source: internal
    #>
    #> attached base packages:
    #> [1] stats     graphics  grDevices utils     datasets  methods   base
    #>
    #> other attached packages:
    #> [1] restrictR_0.1.0
    #>
    #> loaded via a namespace (and not attached):
    #>  [1] digest_0.6.39     R6_2.6.1          fastmap_1.2.0     xfun_0.55
    #>  [5] glue_1.8.0        cachem_1.1.0      knitr_1.51        htmltools_0.5.9
    #>  [9] rmarkdown_2.30    lifecycle_1.0.5   cli_3.6.5         vctrs_0.7.1
    #> [13] svglite_2.2.2     sass_0.4.10       textshaping_1.0.4 jquerylib_0.1.4
    #> [17] systemfonts_1.3.1 compiler_4.5.2    tools_4.5.2       pillar_1.11.1
    #> [21] evaluate_1.0.5    bslib_0.9.0       yaml_2.3.12       otel_0.2.0
    #> [25] rlang_1.1.7       jsonlite_2.0.0