The goal of typewriter is to add type safety to your R code. Under the hood, it mainly uses base R functions to keep its dependencies as low as possible. It lets you
- add types checks to your function arguments with
check_args()
- create typed structures with
typed_struct()
andbase_model()
Typed structures
To make it adding type safety as fun as possible, a typed structure
is just an extended R list where types are defined as type check
functions (like is.integer
) or type strings
(like "integer"
):
my_type <- typed_struct(
a_number = "integer",
some_text = "character"
)
obj <- my_type(
a_number = 1L,
some_text = "typewriter"
)
obj
#> $a_number
#> [1] 1
#>
#> $some_text
#> [1] "typewriter"
class(obj)
#> [1] "list" "typewriter" "my_type"
Try to assign a character value to an integer field:
try(
obj$a_number <- "Hi"
)
#> Error in check_assignment(x, name, value) : Type check failed.
#> value of 'a_number' must be of type integer
Try to create a new object with an invalid type:
try(
my_type(
a_number = 1,
some_text = "typewriter"
)
)
#> Error in my_type(a_number = 1, some_text = "typewriter") :
#> Type check(s) failed
#> ---
#> Type check failed for 'a_number'
#> value: num 1
#> type: double
#> class: numeric
#> length: 1
#> value of 'a_number' must be of type integer
If you use type strings as above, a
typed_struct
is very close to structures in type safe
programming languages like Rust or Go:
# R
Person <- typed_struct(
name = "character",
age = "integer"
)
# R, alternate syntax
Person <- typed_struct(
name = character(),
age = integer()
)
A typed structure could be used as input for a function:
say_hello_to <- function(person) {
stopifnot(inherits(person, "Person"))
paste("Hello", person$name, "you are", person$age, "years old!")
}
hanna <- Person(name = "Hanna", age = 10L)
say_hello_to(hanna)
#> [1] "Hello Hanna you are 10 years old!"
try(say_hello_to(list(name = "Peter", age = 20L)))
#> Error in say_hello_to(list(name = "Peter", age = 20L)) :
#> inherits(person, "Person") is not TRUE
You can also use it to validate a data frame:
df <- data.frame(
name = c("Peter", "Hanna"),
age = c(12L, 10L)
)
Person(df)
#> name age
#> 1 Peter 12
#> 2 Hanna 10
df$id <- 1:2
try(Person(df))
#> Error in Person(df) : Forbidden field(s): id
A type can also be defined as a function that takes the value to be
validated as its only argument and returns TRUE
in case of
success:
my_type <- typed_struct(
a_number = is.integer,
some_text = is.character,
# Add a range check to y
y = function(y) {
is.integer(y) & y > 1 & y < 10
}
)
obj <- my_type(
a_number = 1:2,
some_text = "typewriter",
y = 5L
)
obj
#> $a_number
#> [1] 1 2
#>
#> $some_text
#> [1] "typewriter"
#>
#> $y
#> [1] 5
try(
my_type(
a_number = 1L,
some_text = "typewriter",
y = 1L
)
)
#> Error in my_type(a_number = 1L, some_text = "typewriter", y = 1L) :
#> Type check(s) failed
#> ---
#> Type check failed for 'y'
#> value: int 1
#> type: integer
#> class: integer
#> length: 1
#> expected: {
#> is.integer(y) & y > 1 & y < 10
#> }
Typed functions
With check_args()
you can add type checks to your
functions:
add_two_numbers <- function(a, b) {
check_args(
a = "integer",
b = "integer"
)
a + b
}
add_two_numbers(10L, 20L)
#> [1] 30
try(
add_two_numbers(10, 20)
)
#> Error in base_model(fields)(.x = func_env) : Type check(s) failed
#> ---
#> Type check failed for 'a'
#> value: num 10
#> type: double
#> class: numeric
#> length: 1
#> value of 'a' must be of type integer
#> ---
#> Type check failed for 'b'
#> value: num 20
#> type: double
#> class: numeric
#> length: 1
#> value of 'b' must be of type integer
It is also possible to add the type definition directly to the function arguments to make it visible to the user:
multiply_two_numbers <- function(a = "integer", b = "integer:1") {
check_args()
a * b
}
try(
multiply_two_numbers(10L, 1:2)
)
#> Error in base_model(fields)(.x = func_env) : Type check(s) failed
#> ---
#> Type check failed for 'b'
#> value: int [1:2] 1 2
#> type: integer
#> class: integer
#> length: 2
#> value of 'b' must be of type integer(1)
As you can see in the example above you can also check the length of
the value by adding the length to the type string
"integer:1"
.
The base_model()
function
Under the hood typed_struct()
and
check_args()
use base_model()
that got its
name from Pydantic’s
BaseModel
class. It gives you further options like
validating your inputs before the assignment:
my_model <- base_model(
a = "integer",
b = "double",
.validators_before = list(
a = as.integer,
b = function(b) round(b, 2)
)
)
obj <- my_model(a = 10, b = 20.123456)
typeof(obj$a)
#> [1] "integer"
obj
#> $a
#> [1] 10
#>
#> $b
#> [1] 20.12