{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, andtypedr_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(), andprint_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
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.
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: 3LBut 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).
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)LogicalIntegerDoubleCharacterListEnvironmentFactorMatrixData.frameDateTime(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: TRUEThe | 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.
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: FALSEUseful 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: TRUEThis is often better done by defining a wrapper as shown below.
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.
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`.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.
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] TRUEGuards 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.
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.
See vignette("typedr-in-packages", "typedr") or the Article section if
you’re browsing the pkgdown website.
{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}.
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:
- Gabor Csardy’s argufy
- Richie Cotton’s assertive
- Tony Fishettti’s assertr
- Hadley Wickham’s assertthat
- Michel Lang’s checkmate
- Joe Thorley’s checkr
- Joe Thorley’s chk
- Aviral Goel’s contractr
- Stefan Bache’s ensurer
- Brian Lee Yung Rowe’s lambda.r
- Kun Ren’s rtype
- Duncan Temple Lang’s TypeInfo
- Jim Hester’s types
