
sicher (German for safe or certain and pronounced zeesher) is an R package that brings runtime type safety to R programming, inspired by TypeScript for JavaScript. Declare types for your variables and have them enforced automatically on every assignment, catching type errors early and making your code more robust and self-documenting. 🛡️
sicher implements runtime type safety in R by
attaching types to variables via active bindings rather than traditional
attributes or classes. When a variable is declared with a type (e.g.,
x %:% Numeric %<-% 5), it is replaced by an active
binding that wraps the value inside a closure with a private environment
storing both the value and its associated type.
Every read and write to the variable is intercepted: reads return the stored value, while writes trigger validation through the type’s checker function before updating. This design provides strong guarantees that values cannot violate their declared types, enabling expressive and composable type definitions (including unions, structured lists, and data frame schemas).
You can install the sicher package from
CRAN with:
install.packages("sicher")or install the development version from GitHub:
# install.packages("devtools")
devtools::install_github("feddelegrand7/sicher")library(sicher)
# Annotate a variable with a type, then assign a value
name %:% String %<-% "Alice"
age %:% Numeric %<-% 30
# The type is enforced on every subsequent assignment
age <- "thirty" # Error: Type error
#> Error: Type error in 'age': Expected numeric, got string
#> Received: thirtysicher ships with a complete set of primitive and container types:
| Type | Checks |
|---|---|
Integer |
is.integer() |
Double |
is.double() |
Numeric |
is.numeric() |
String |
is.character() |
Bool |
is.logical() |
List |
is.list() |
DataFrame |
is.data.frame() |
Function |
is.function() |
Any |
always passes |
Null |
is.null() |
x %:% Integer %<-% 42L
y %:% Double %<-% 3.14
flag %:% Bool %<-% TRUE
df %:% DataFrame %<-% data.frame(a = 1:3)| Operator | Purpose |
|---|---|
%:% |
Annotate a variable with a type |
%<-% |
Assign a value (type-checked) |
Scalar() —
single-element valuessingle %:% Scalar(Numeric) %<-% 42
single <- c(1, 2, 3) # Error: length > 1
#> Error: Type error in 'single': Expected scalar<numeric>, got double of length 3
#> Received: [1, 2, 3]Readonly() —
immutable variablesPI %:% Readonly(Double) %<-% 3.14159
PI <- 3.0 # Error: cannot reassign readonly variable
#> Error: Cannot reassign readonly variable 'PI'. Remove Readonly() from the type declaration if mutation is needed.Optional() — nullable
valuesmiddle_name %:% Optional(String) %<-% NULL # OK
middle_name <- "Marie" # Also OK
middle_name <- 123 # Error: not string or null
#> Error: Type error in 'middle_name': Expected string | null, got double
#> Received: 123NonNA() — reject NA
valuesPrevents any NA from entering a typed variable — a
common source of silent bugs in data pipelines. The underlying type is
validated first; NonNA adds an extra NA-free guarantee on
top.
salary %:% NonNA(Numeric) %<-% c(1800, 2300, 4000)
salary <- c(1800, NA, 4000) # Error: value contains NA(s)
#> Error: Type error: expected non_na<numeric> (no NA values), but value contains NA(s)Composes freely with other modifiers:
clean_tag %:% NonNA(Scalar(String)) %<-% "admin"
clean_tag <- NA_character_ # Error
#> Error: Type error: expected non_na<scalar<string>> (no NA values), but value contains NA(s)Between(min, max) — closed-interval rangeAccepts numeric values within the closed interval
[min, max]. Every element of a vector must satisfy the
bounds. NA values are always rejected.
age %:% Between(0, 150) %<-% 30
score %:% Between(0.0, 1.0) %<-% 0.95
score <- 1.5 # Error: 1.5 is outside [0, 1]
#> Error: Type error: expected between[0, 1], but value [1.5] is outside [0, 1]
score <- NA # Error: value contains NA(s)
#> Error: Type error: Expected between[0, 1], got bool
#> Received: NAMatches(pattern) — regex-constrained stringsValidates every element of a character vector against a
Perl-compatible regex. The pattern is compiled eagerly at construction
time, so typos in the regex are caught immediately. An empty
character(0) passes vacuously; pair with
NonEmpty() to also require at least one element.
email %:% Matches("^[^@]+@[^@]+\\.[^@]+$") %<-% "user@example.com"
email <- "not-an-email" # Error: does not match pattern
#> Error: Type error: expected matches("^[^@]+@[^@]+\.[^@]+$"), but value ["not-an-email"] does not match the pattern
hex_color %:% NonEmpty(Matches("^#[0-9A-Fa-f]{6}$")) %<-% c("#FF5733", "#1A2B3C")NonEmpty() — reject empty vectors and listsRejects zero-length vectors, strings, and lists. For data frames it
checks nrow > 0. The base type is validated first, then
the length is checked.
tags %:% NonEmpty(String) %<-% c("r", "types")
tags <- character(0) # Error: length > 0
#> Error: Type error: expected non_empty<string> (length > 0), but got an empty value
tags <- "r" # OK — length 1 is fineAccept more than one type with |:
id %:% (String | Numeric) %<-% "user123"
id <- 456 # Also OK
id <- TRUE # Error: not string or numeric
#> Error: Type error in 'id': Expected string | numeric, got bool
#> Received: TRUERestrict a value to an explicit set of allowed values with
Enum():
status %:% Enum("draft", "published", "archived") %<-% "draft"
status <- "published" # OK
status <- "deleted" # Error: not one of the allowed enum values
#> Error: Type error in 'status': Expected enum["draft", "published", "archived"], got string
#> Received: deleted
priority %:% Enum(1, 2, 3) %<-% 2
priority <- 3 # OK
priority <- 5 # Error
#> Error: Type error in 'priority': Expected enum[1, 2, 3], got double
#> Received: 5Use Literal() when the value itself is part of the type,
similar to TypeScript literal types:
direction %:% Literal("left", "right") %<-% "left"
direction <- "right" # OK
direction <- c("left", "right") # Error: Literal only accepts scalar values
#> Error: Type error in 'direction': Expected literal["left", "right"], got string of length 2
#> Received: [left, right]
status_code %:% Literal(200, 404) %<-% 200
status_code <- 404 # OK
status_code <- 200L # Error: 200L is an integer, not the double literal 200
#> Error: Type error in 'status_code': Expected literal[200, 404], got integer
#> Received: 200Append [n] to any type to require an exact vector
length:
coords %:% Numeric[3] %<-% c(1, 2, 3)
coords <- c(4, 5, 6) # OK — same length
coords <- c(1, 2) # Error: wrong length
#> Error: Type error in 'coords': Expected numeric[3], got double of length 2
#> Received: [1, 2]Define object-like schemas with create_list_type():
Person <- create_list_type(list(
name = String,
age = Numeric,
email = Optional(String) # nullable field
))
person %:% Person %<-% list(name = "Alice", age = 30, email = "alice@example.com")
person <- list(name = "Bob") # Error: missing required field 'age'
#> Error: Type error: Expected {name: string, age: numeric, email?: string | null}, got list
#> Details: Missing required field(s): age (expected fields: name, age)
#> Received: list with fields: [name]Use extend() when a new schema should reuse an existing
list type instead of redefining every field:
Employee <- extend(Person, list(
role = String,
department = Optional(String)
))
employee %:% Employee %<-% list(
name = "Alice",
age = 30,
role = "Engineer"
)
employee <- list(
name = "Alice",
age = 30,
role = "Engineer",
department = "Platform"
)If extra reuses a field name from the base type, the new
field definition overrides the old one and extend() emits a
warning so the change is explicit.
Validate column names and types with
create_dataframe_type():
UserTable <- create_dataframe_type(list(
id = Integer,
username = String,
active = Bool
))
users %:% UserTable %<-% data.frame(
id = 1:2,
username = c("alice", "bob"),
active = c(TRUE, FALSE)
)
# Wrong column type fails immediately
users <- data.frame(
id = c("1", "2"), # Error: id must be integer
username = c("alice", "bob"),
active = c(TRUE, FALSE)
)
#> Error: Type error in 'id': Expected integer, got string of length 2
#> Received: [1, 2]ListOf()Validate every element of a list against the same type:
TodoItem <- create_list_type(list(
id = Numeric,
title = String,
completed = Bool
))
TodoList <- ListOf(TodoItem)
todos %:% TodoList %<-% list(
list(id = 1, title = "Buy milk", completed = FALSE),
list(id = 2, title = "Read book", completed = TRUE)
)
todos <- list(
list(id = 1, title = "Buy milk", completed = FALSE),
list(wrong = "shape") # Error: element does not match TodoItem
)
#> Error: Type error in 'todos': Expected list<{id: numeric, title: string, completed: bool}>, got list of length 2
#> Received: list of length 2Use create_type() to define your own validator with any
predicate function:
Positive <- create_type("positive", function(x) is.numeric(x) && all(x > 0))
value %:% Positive %<-% 5
value <- -1 # Error
#> Error: Type error in 'value': Expected positive, got double
#> Received: -1Use Literal() for TypeScript-style exact scalar values,
Enum() for membership in a finite set that may also allow
vectors of allowed values, and create_type() when the rule
is more general than membership in a predefined list.
Use typed_function() to wrap any function with runtime
type checks on its parameters and, optionally, its return value — a
typed function signature for R:
# Basic typed function — checks params and return type
add <- typed_function(
function(x, y) x + y,
params = list(x = Numeric, y = Numeric),
.return = Numeric
)
add(1, 2) # Returns 3
#> [1] 3
add("a", 2) # Error: Type error in 'x'
#> Error: Type error in 'x': Expected numeric, got string
#> Received: aNum_not_inf <- create_type(
name = "Num_not_inf",
checker = function(x) {
is.numeric(x) && !is.infinite(x)
}
)
divide <- function(a, b) {
return(a / b)
}
divide_safe <- typed_function(
fn = divide,
params = list(
a = Num_not_inf,
b = Num_not_inf
),
.return = Num_not_inf
)
divide_safe(10, 2) # works normally
#> [1] 5
divide_safe(10, 0) # fails as 10/0 returns Inf
#> Error: Type error in '<return value>': Expected Num_not_inf, got double
#> Received: Inf# Optional parameter
greet <- typed_function(
function(name, title = NULL) {
if (is.null(title)) paste("Hello,", name)
else paste("Hello,", title, name)
},
params = list(name = String, title = Optional(String))
)
greet("Alice") # "Hello, Alice"
#> [1] "Hello, Alice"
greet("Alice", title = "Dr.") # "Hello, Dr. Alice"
#> [1] "Hello, Dr. Alice"
greet("Alice", title = 42) # Error: Type error in 'title'
#> Error: Type error in 'title': Expected string | null, got double
#> Received: 42# Union type in params
describe <- typed_function(
function(id) paste("ID:", id),
params = list(id = String | Numeric),
.return = String
)
describe("abc") # "ID: abc"
#> [1] "ID: abc"
describe(123) # "ID: 123"
#> [1] "ID: 123"
describe(TRUE) # Error: Type error in 'id'
#> Error: Type error in 'id': Expected string | numeric, got bool
#> Received: TRUEYou can also define an object to expect as a return value:
Person <- create_list_type(
type_spec = list(
name = String,
age = Numeric
)
)
get_person_info_as_list <- function(name, age) {
return(list(
name = name,
age = age
))
}
get_person_info_as_message <- function(name, age) {
return(
paste("Hi my name is ", name, " I'm ", age, " years old")
)
}
get_person_info_as_list_safe <- typed_function(
fn = get_person_info_as_list,
params = list(name = String, age = Numeric),
.return = Person
)
get_person_info_as_list_safe(name = "Omar", age = 30) # works fine
#> $name
#> [1] "Omar"
#>
#> $age
#> [1] 30
get_person_info_as_message_safe <- typed_function(
fn = get_person_info_as_message,
params = list(name = String, age = Numeric),
.return = Person
)
# Should fail as the function does not return a Person list anymore
get_person_info_as_message_safe(name = "Omar", age = 30)
#> Error: Type error in '<return value>': Expected {name: string, age: numeric}, got string
#> Received: Hi my name is Omar I'm 30 years old# Catch bad payroll data early instead of getting silent NAs
calculate_mean_payroll <- function(salaries) {
salaries %:% Numeric %<-% salaries
mean(salaries)
}
calculate_mean_payroll(c(1800, 2300, 4000)) # Works fine
#> [1] 2700
calculate_mean_payroll(c(1800, "2300", 4000)) # Error: type mismatch
#> Error: Type error in 'salaries': Expected numeric, got string of length 3
#> Received: [1800, 2300, 4000]You can automatically infer the type for an R object using
infer_type():
# Primitives
infer_type(42L) # Integer
#> <type: integer >
infer_type(3.14) # Double
#> <type: double >
infer_type("abc") # String
#> <type: string >
infer_type(TRUE) # Bool
#> <type: bool >
infer_type(NULL) # Null
#> <type: null >
infer_type(function(x) x + 1) # Function
#> <type: Function >
# Default mode infers types, not observed lengths
infer_type(c(1L, 2L, 3L)) # Integer
#> <type: integer >
infer_type(c(1, 2, 3)) # Double
#> <type: double >
infer_type(c("a", "b")) # String
#> <type: string >
infer_type(c(TRUE, FALSE)) # Bool
#> <type: bool >
# Named and unnamed lists
infer_type(list(a = 1L, b = "x")) # create_list_type(list(a = Integer, b = String))
#> <type: {a: integer, b: string} >
infer_type(list(1L, 2L, 3L)) # ListOf(Integer)
#> <type: list<integer> >
infer_type(list(1L, "a")) # List
#> <type: list >
infer_type(list(a = NULL, b = 1)) # create_list_type(list(a = Optional(Any), b = Double))
#> <type: {a?: any | null, b: double} >
# Data frames
infer_type(data.frame(x = 1:3, y = c("a", "b", "c"), stringsAsFactors = FALSE))
#> <type: data.frame{x: integer, y: string} >
# create_dataframe_type(list(x = Integer, y = String))
# Use strict = TRUE to also infer Scalar() and [n] size constraints
infer_type(42L, strict = TRUE) # Scalar(Integer)
#> <type: scalar<integer> >
infer_type(c("a", "b"), strict = TRUE) # String[2]
#> <type: string[2] >
infer_type(data.frame(x = 1:3), strict = TRUE)
#> <type: data.frame{x: integer[3]} >The behavior of sicher can be controlled globally
using R’s options() mechanism. These options allow you to
switch between strict type enforcement and a fully disabled mode
depending on your use case.
sicher.modeControls how typed assignments (%:% and
%<-%) behave.
"on" (default)"off"<-)You can configure the mode globally, for example, disabling strict type checking with:
options(sicher.mode = "off")Or enable strict typing with:
options(sicher.mode = "on") # this is the default"on" (default)
(strict mode) Use in situations where correctness and
safety are important:
"off" (disabled mode)
Use when you want to bypass the type system entirely:
my_var_x %:% Numeric %<-% 10 # validated and bound with type enforcement
my_var_x <- "string" # trigger an error
#> Error: Type error in 'my_var_x': Expected numeric, got string
#> Received: stringoptions(sicher.mode = "off")
my_var_y %:% Numeric %<-% 10
my_var_y <- "string" # nothing happensIt can also be temporarily scoped:
withr::with_options(
list(sicher.mode = "off"),
{
my_var_y %:% Numeric %<-% 10
my_var_y <- "string"
}
)Full documentation and worked examples are available at the package website.
Please note that the sicher project is released with a Contributor Code of Conduct. By contributing to this project, you agree to abide by its terms.