Skip to content

Repository files navigation

R-CMD-check Codecov test coverage

typedr

{typedr} adds lightweight runtime type constraints that feel native in R code. It follows the public model of {typed} by moodymudskipper.

It has several main features:

  • set variable types in a script or the body of a function, so they can’t be assigned illegal values
  • set argument types in a function definition
  • set return type of a function
  • combine and condition function argument types using compact typedr syntax

The user can define their own types, or leverage assertions from other packages.

Under the hood variable types use active bindings, so once a variable is restricted by an assertion, it cannot be modified in a way that would not satisfy it.

Compared with {typed}, this package focuses on modern internals and developer experience:

  • expression handling, environments, and condition plumbing use {rlang}
  • errors are emitted through {cli} with typed condition classes such as typedr_type_error, typedr_type_mismatch, and typedr_return_error
  • printed output for typed functions, assertion factories, assertions, and typed values is structured, color-aware, and easier to inspect
  • helper printers such as print_typedr(), print_all_args(), print_whole_fn(), print_whole_value(), and print_stats() expose the richer print layer when you need more detail
  • typed function print output shows the rewritten body, return/argument types, optional truncation hints, and (once per session) a note when prettycode is not installed; install prettycode for fuller syntax highlighting

Installation

Install CRAN version with:

install.packages("typedr")

or development version with :

remotes::install_github("pacmandoh/typedr")

And attach with :

library(typedr, warn.conflicts = FALSE)

warn.conflicts = FALSE suppresses R’s default masking messages for all typedr exports and shows typedr’s own startup summary instead. Use options(typedr.quiet = TRUE) to hide that summary.

Set variable type

Question mark notation and declare

Here are examples on how we would set types

Character() ? x # restrict x to "character" type
x <- "a"
x
#> value: <character> [1] "a"
#> • assertion: <Character()>

Integer(3) ? y <- 1:3 # restrict y to "integer" type of length 3
y
#> value: <integer> [3] 1, 2, 3
#> • assertion: <Integer(3)>

We cannot assign values of the wrong type to x and y anymore.

x <- 2
#> Error:
#> ! Can't assign to `x`.
#> Caused by error in `Character()`:
#> ! type mismatch
#> ✖ typeof: "double", expected: "character"

y <- 4:5
#> Error:
#> ! Can't assign to `y`.
#> Caused by error in `Integer()`:
#> ! length mismatch
#> ✖ length: 2L, expected: 3L

But the right type will work.

x <- c("b", "c")

y <- c(1L, 10L, 100L)

declare is a strict equivalent, slightly more explicit, which looks like base::assign.

declare("x", Character())
x <- "a"
x
#> value: <character> [1] "a"
#> • assertion: <Character()>

declare("y", Integer(3), 1:3)
y
#> value: <integer> [3] 1, 2, 3
#> • assertion: <Integer(3)>

Declaring a variable without an initial value (Character() ? x or declare("x", Character())) leaves it unset until you assign. In an interactive session at the top level, typedr informs you that the variable was declared but still unset. After assignment, reading the variable at the REPL shows typedr’s value printer (data plus assertion metadata).

Assertion factories and assertions

Integer and Character are function factories (functions that return functions), thus Integer(3) and Character() are functions.

The latter functions check a value and, on success, return it generally unmodified. For instance:

Integer(3)(1:2)
#> Error in `Integer()`:
#> ! length mismatch
#> ✖ length: 2L, expected: 3L

Character()(3)
#> Error in `Character()`:
#> ! type mismatch
#> ✖ typeof: "double", expected: "character"

We call Integer(3) and Character() assertions, and we call Integer and Character assertion factories. They are also called types, but should not be confused with the atomic types returned by typeof().

The package contains many assertion factories (see ?assertion_factories), the main ones are:

  • Any (No default restriction)
  • Logical
  • Integer
  • Double
  • Character
  • List
  • Environment
  • Factor
  • Matrix
  • Data.frame
  • Date
  • Time (POSIXct)

Assertions can be combined with | and &.

Number <- Integer() | Double()
Number(1L)
#> [1] 1
Number(1)
#> [1] 1
Number("a")
#> Error in `Number()`:
#> ! No matching <Type()>.
#> ✖ Expected one of: <Integer() | Double()>.

PositiveInteger <- Integer() & Any(... = ~ . > 0L)
PositiveInteger(1L)
#> [1] 1
PositiveInteger(0L)
#> Error in `Any()`:
#> ! mismatch
#> ✖ value > 0L: FALSE, expected: TRUE

The | operator is a union: a value is accepted if any assertion accepts it. The & operator is an intersection: every assertion must accept the value, in order. For compatibility with R’s usual combining idiom, c(Integer(), Double()) is also accepted and means the same as Integer() | Double(). The | notation is usually clearer in function signatures.

Advanced type restriction using arguments

As we’ve seen with Integer(3), passing arguments to an assertion factory restricts the type.

For instance Integer has arguments length allow_null and .... We already used length, allow_null is convenient to allow a default NULL value in addition to the "integer" type.

The arguments can differ between assertion factories, for instance Data.frame has nrow, ncol, each, allow_null and ...

Data.frame() ? x <- iris
Data.frame(ncol = 2) ? x <- iris
#> Error:
#> ! Can't assign to `x`.
#> Caused by error in `Data.frame()`:
#> ! ncol mismatch
#> ✖ ncol: 5L, expected: 2L
Data.frame(each = Double()) ? x <- iris
#> Error:
#> ! Can't assign to `x`.
#> Caused by error in `Data.frame()`:
#> ! column 5 ("Species") failed.
#> Caused by error in `Double()`:
#> ! type mismatch
#> ✖ typeof: "integer", expected: "double"

In the dots we can use arguments named as functions and with the value of the expected result.

# Integer has no anyNA arg but we can still use it because a function named
# this way exists
Integer(anyNA = FALSE) ? x <- c(1L, 2L, NA)
#> Error in `Integer()`:
#> ! `anyNA` mismatch
#> ✖ anyNA: TRUE, expected: FALSE

Useful arguments might be for instance, anyDuplicated = 0L, names = NULL, attributes = NULL… Any available function can be used.

That makes assertion factories very flexible. If that is still not flexible enough, arguments named ... can add custom restrictions. For repeated use, this is usually better expressed as a wrapper. The example below assigns an invalid value on purpose to show the custom message from the ... check:

Character(1, ... = "`value` is not a fruit!" ~ . %in% c("apple", "pear", "cherry")) ?
  x <- "potatoe"
#> Error in `Character()`:
#> ! `value` is not a fruit!
#> ✖ value %in% c("apple", "pear", "cherry"): FALSE, expected: TRUE

This is often better done by defining a wrapper as shown below.

Concise diagnostics

typedr keeps generated errors focused on the user-facing assertion. Internal wrapper names such as f() are replaced by calls such as Double() or a custom factory name. Repeated container failures show only the first failed item or column and the number of remaining failures. Long names, expressions, values, and union candidate lists are shortened in diagnostic bullets. Exceptionally long union or intersection parent calls use the neutral Type() label; long single-factory calls fall back to the factory name (for example Character()).

many_columns <- as.data.frame(setNames(
  rep(list(1L), 4),
  c("first", "a very long second column name", "third", "fourth")
))
Data.frame(each = Double()) ? compact_example <- many_columns
#> Error:
#> ! Can't assign to `compact_example`.
#> Caused by error in `Data.frame()`:
#> ! 4 columns failed.
#> ✖ First: column 1 ("first"); and 3 more.
#> Caused by error in `Double()`:
#> ! type mismatch
#> ✖ typeof: "integer", expected: "double"

The first underlying assertion error remains attached as the parent condition, so rlang::last_trace() still contains the useful root cause without expanding every repeated failure.

Constants

To define a constant, we just surround the variable by parentheses (think of them as a protection)

Double() ? (x) <- 1
x <- 2
#> Error:
#> ! Can't assign to constant `x`.

# defining a type is optional
? (y) <- 1
y <- 2
#> Error:
#> ! Can't assign to constant `y`.

Set a function’s argument type

We can set argument types this way :

add <- ? function (x= ? Double(), y= 1 ? Double()) {
  x + y
}

Note that we started the definition with a ?, and that we gave a default to y, but not x. Note also the = sign next to x, necessary even when we have no default value. If you forget it you’ll have an error “unexpected ? in …”.

Printing the function shows the compiled body.

add
#> <typedr function>
#> Return: <Any()>
#> Arguments:
#> • `x`: <Double()>
#> • `y`: <Double()> (default: 1)
#>
#> function (x, y = 1)
#> {
#>   if (base::missing(x)) {
#>     .typedr_contract_fail_missing("x")
#>   }
#>   if (.typedr_typeof_is(x, "double")) {
#>   }
#>   else .typedr_contract_fail_arg("x", quote(Double()), value = x)
#>   if (.typedr_typeof_is(y, "double")) {
#>   }
#>   else .typedr_contract_fail_arg("y", quote(Double()), value = y)
#>   x + y
#> }

Let’s test it by providing a right and wrong type.

add(2, 3)
#> [1] 5
add(2, 3L)
#> Error in `add()`:
#> ! Can't check `y` in `add()`.
#> Caused by error in `Double()`:
#> ! type mismatch
#> ✖ typeof: "integer", expected: "double"

If we want to restrict x and y to the type “integer” in the rest of the body, so they cannot be overwritten by character for instance,we can use the ?+ notation :

add <- ? function (x= ?+ Double(), y= 1 ?+ Double()) {
  x + y
}

add
#> <typedr function>
#> Return: <Any()>
#> Arguments:
#> • `x`: <Double()>
#> • `y`: <Double()> (default: 1)
#>
#> function (x, y = 1)
#> {
#>   .typedr_contract_bind("x", x, .typedr_contract_checker_x,
#>     assertion_expr = quote(Double()))
#>   .typedr_contract_bind("y", y, .typedr_contract_checker_y,
#>     assertion_expr = quote(Double()))
#>   x + y
#> }

We see that it installs an active binding so later assignments are checked as well.

Combine and link argument types

Union and intersection types can be used directly in function signatures.

as_number <- ? function(x = ? Integer() | Double()) {
  x
}

as_number(1L)
#> [1] 1
as_number(1)
#> [1] 1
as_number("a")
#> Error in `as_number()`:
#> ! Can't check `x` in `as_number()`.
#> Caused by error in `Integer() | Double()`:
#> ! No matching <Type()>.
#> ✖ Expected one of: <Integer() | Double()>.

Arguments can also depend on other arguments. Use a two-sided formula after ?: the left side is a guard, and the right side is the assertion that applies to the current argument when the guard matches.

scale_value <- ? function(
  x = ? Integer() | Character(),
  scale = ? x:Integer() ~ Double()
) {
  TRUE
}

scale_value(1L, 2)
#> [1] TRUE
scale_value(1L, 2L)
#> Error in `scale_value()`:
#> ! Can't check `scale`.
#> ℹ Guard `x:Integer()` matched; expected <Double()>.
#> Caused by error in `Double()`:
#> ! type mismatch
#> ✖ typeof: "integer", expected: "double"
scale_value("a", "scale is ignored")
#> [1] TRUE

Guards use arg:Type() and can be combined with |, &, parentheses, and !.

dependent <- ? function(
  a1 = ? Any(),
  a2 = ? Any(),
  out = ? a1:Integer() | a2:Character() ~ Double()
) {
  TRUE
}

dependent(1L, FALSE, 1)
#> [1] TRUE
dependent(FALSE, "x", 1L)
#> Error in `dependent()`:
#> ! Can't check `out`.
#> ℹ Guard `a1:Integer() | a2:Character()` matched; expected <Double()>.
#> Caused by error in `Double()`:
#> ! type mismatch
#> ✖ typeof: "integer", expected: "double"

By default, a guard that does not match simply leaves the dependent argument alone. Add / Warning() or / Error() to report when the dependent argument is supplied but inactive.

optional_scale <- ? function(
  x = NULL ? (Null() | Integer()),
  scale = ? !x:Missing() ~ Double() / Warning()
) {
  TRUE
}

optional_scale()
#> [1] TRUE
optional_scale(x = NULL, scale = 2)
#> Warning in optional_scale(x = NULL, scale = 2): ! `scale` is inactive.
#>   ℹ Guard `!x:Missing()` did not match.
#> [1] TRUE
optional_scale(x = 1L, scale = 2)
#> [1] TRUE
optional_scale(x = 1L, scale = 2L)
#> Error in `optional_scale()`:
#> ! Can't check `scale`.
#> ℹ Guard `!x:Missing()` matched; expected <Double()>.
#> Caused by error in `Double()`:
#> ! type mismatch
#> ✖ typeof: "integer", expected: "double"

Missing() is a guard-only helper: it matches when the argument was not supplied or when its value is NULL. It is intentionally not a standalone exported type. Warning(Type()) can also be used on the right side to warn rather than error when the guard matches but the dependent type check fails.

Set a function’s return type

To set a return type we use ? before the function definition as in the previous section, but we type an assertion on the left hand side.

add_or_subtract <- Double() ? function (x, y, subtract = FALSE) {
  if(subtract) return(x - y)
  x + y
}
print_whole_fn(add_or_subtract)
#> <typedr function>
#> Return: <Double()>
#>
#> function (x, y, subtract = FALSE)
#> {
#>   if (subtract)
#>     return({
#>       .typedr_return_value <- x - y
#>       if (.typedr_typeof_is(.typedr_return_value, "double")) {
#>       } else .typedr_contract_fail_return(quote(Double()),
#>         value = .typedr_return_value)
#>       if (.typedr_is_typedr_value(.typedr_return_value)) {
#>         .typedr_peel_value(.typedr_return_value)
#>       } else {
#>         .typedr_return_value
#>       }
#>     })
#>   {
#>     .typedr_return_value <- x + y
#>     if (.typedr_typeof_is(.typedr_return_value, "double")) {
#>     }
#>     else .typedr_contract_fail_return(quote(Double()), value = .typedr_return_value)
#>     if (.typedr_is_typedr_value(.typedr_return_value)) {
#>       .typedr_peel_value(.typedr_return_value)
#>     }
#>     else {
#>       .typedr_return_value
#>     }
#>   }
#> }

Return expressions are checked at the function boundary. Callers receive plain R values after the check passes; declare() bindings stay inside the function.

Use typedr in a package and define your own types

See vignette("typedr-in-packages", "typedr") or the Article section if you’re browsing the pkgdown website.

Relationship with {typed}

{typedr} is derived from, and deeply indebted to, {typed}. The original package established the public syntax and the central model used here: ? for declaring typed variables, typed function arguments, and typed return values; assertion factories such as Integer() and Character(); and active bindings for runtime assignment checks.

The goal of {typedr} is not to erase that lineage. It is to carry the same idea forward with a codebase that leans on the modern tidyverse infrastructure available today, especially {rlang} and {cli}. Many concepts, examples, and interfaces will therefore feel familiar to users of {typed}, while error objects and print output are intentionally more structured in {typedr}.

Acknowledgements

This package would not exist without {typed}. Thank you to moodymudskipper for designing and releasing the original package, and for making such an imaginative experiment in R runtime typing available to the community. {typedr} is a grateful continuation of that work.

The original {typed} README also acknowledged Jim Hester and Gabor Csardi’s work and many great efforts on static typing, assertions, or annotations in R. We keep that acknowledgement here with appreciation:

About

Runtime types for R variables, arguments, and returns. Typed functions compile checks at definition

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages