Skip to contents

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

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:

# Rust
struct Person {
    name: String,
    age: u8
}
# Go
type Person struct {
  name string
  age int
}
# 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