library(sicher)
# Basic types
x %:% Integer %<-% 42L
y %:% Double %<-% 3.14
z %:% Numeric %<-% 100
name %:% String %<-% "Alice"
flag %:% Bool %<-% TRUE
items %:% List %<-% list(1, 2, 3)
anything %:% Any %<-% "could be anything"
nothing %:% Null %<-% NULL
func %:% Function %<-% function(x) x + 1
df %:% DataFrame %<-% data.frame(a = 1:3, b = letters[1:3])Features
Core Features
Built-in Types
sicher provides a comprehensive set of built-in types:
Type Modifiers
Scalar Types
Enforce single values (vectors of length 1):
# This works
single_value %:% Scalar(Numeric) %<-% 42
# This fails
single_value <- c(1, 2, 3)Error: Type error in 'single_value': Expected scalar<numeric>, got double of length 3
Received: [1, 2, 3]
Readonly Types
Prevent reassignment after initial value:
# Set a readonly constant
PI %:% Readonly(Double) %<-% 3.14159
PI <- 3.0 # This failsError: Cannot reassign readonly variable 'PI'. Remove Readonly() from the type declaration if mutation is needed.
Optional Types
Allow NULL values in addition to the base type:
PI <- 3.0Error: Cannot reassign readonly variable 'PI'. Remove Readonly() from the type declaration if mutation is needed.
# Optional string (can be string or NULL)
middle_name %:% Optional(String) %<-% NULL
middle_name <- "Marie" # Also OK
middle_name <- 123 # This failsError: Type error in 'middle_name': Expected string | null, got double
Received: 123
NonNA — reject NA values
Prevents any NA from entering a typed variable. R’s silent NA propagation is a frequent source of subtle bugs in data pipelines; NonNA blocks it at the boundary.
salary %:% NonNA(Numeric) %<-% c(1800, 2300, 4000)
salary # [1] 1800 2300 4000[1] 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 with other modifiers — here combined with Scalar to require a single, non-NA string:
label %:% NonNA(Scalar(String)) %<-% "active"
label <- NA_character_ # ErrorError: Type error: expected non_na<scalar<string>> (no NA values), but value contains NA(s)
Between — closed-interval numeric range
Accepts numeric values within the closed interval [min, max]. Every element of a vector must satisfy the bounds, and NA values are always rejected.
# Scalar usage
age %:% Between(0, 150) %<-% 30
age <- 200 # Error: 200 is outside [0, 150]Error: Type error: expected between[0, 150], but value [200] is outside [0, 150]
# Vector usage — all elements must be in range
score %:% Between(0.0, 1.0) %<-% c(0.1, 0.95, 0.5)
score <- c(0.1, 1.5) # Error: 1.5 is outside [0, 1]Error: Type error: expected between[0, 1], but value [1.5] is outside [0, 1]
Between works with integer storage because is.numeric(1L) is TRUE in R:
count %:% Between(0L, 100L) %<-% 42LMatches — regex-constrained strings
Validates every element of a character vector against a Perl-compatible regular expression. The regex is compiled eagerly at construction time, so invalid patterns are caught immediately rather than at first use.
email %:% Matches("^[^@]+@[^@]+\\.[^@]+$") %<-% "user@example.com"
email <- "not-an-email" # Error: does not match patternError: Type error: expected matches("^[^@]+@[^@]+\.[^@]+$"), but value ["not-an-email"] does not match the pattern
Applies element-wise to vectors:
codes %:% Matches("^[A-Z]{3}$") %<-% c("USD", "EUR", "GBP")
codes <- c("USD", "us") # Error: "us" does not matchError: Type error: expected matches("^[A-Z]{3}$"), but value ["us"] does not match the pattern
An empty character(0) passes vacuously. Pair with NonEmpty when at least one element is required:
tags %:% NonEmpty(Matches("^[a-z]+$")) %<-% c("foo", "bar")
tags <- character(0) # Error: length > 0Error: Type error: expected non_empty<matches("^[a-z]+$")> (length > 0), but got an empty value
tags <- c("foo", "BAR") # Error: "BAR" does not matchError: Type error: expected matches("^[a-z]+$"), but value ["BAR"] does not match the pattern
NonEmpty — reject empty vectors and lists
Rejects zero-length vectors, character vectors, and lists. For data frames it checks nrow > 0. The underlying type is validated first; NonEmpty adds the non-emptiness guarantee on top.
tags %:% NonEmpty(String) %<-% c("r", "types")
tags <- character(0) # Error: length > 0Error: Type error: expected non_empty<string> (length > 0), but got an empty value
items %:% NonEmpty(List) %<-% list(1, 2, 3)
items <- list() # Error: length > 0Error: Type error: expected non_empty<list> (length > 0), but got an empty value
For data frames, an empty-row frame is rejected while a zero-column frame never passes the schema check first:
Row <- create_dataframe_type(list(id = Integer, name = String))
NonEmptyRow <- NonEmpty(Row)
valid_df %:% NonEmptyRow %<-% data.frame(id = 1L, name = "Alice")
valid_df <- data.frame(id = integer(0), name = character(0)) # Error: length > 0Error: Type error: expected non_empty<data.frame{id: integer, name: string}> (length > 0), but got an empty value
Vector Size Types
Specify exact vector lengths:
# Exactly 3 numeric values
coords %:% Numeric[3] %<-% c(1, 2, 3)
coords <- c(4, 5, 6) # This works
coords <- c(1, 2) # Too shortError: Type error in 'coords': Expected numeric[3], got double of length 2
Received: [1, 2]
Union Types
Allow multiple types using the | operator:
# Accept either string or numeric
id %:% (String | Numeric) %<-% "user123"
id <- 456 # Also OK
id <- TRUEError: Type error in 'id': Expected string | numeric, got bool
Received: TRUE
Enum Types
Restrict values to a predefined finite set:
# Status can only be one of these strings
status %:% Enum("draft", "published", "archived") %<-% "draft"
status <- "published"
# Numeric enums also work
priority %:% Enum(1, 2, 3) %<-% 2status <- "deleted"Error: Type error in 'status': Expected enum["draft", "published", "archived"], got string
Received: deleted
Structured List Types
Define object-like structures for lists:
# Define a Person type
Person <- create_list_type(list(
name = String,
age = Numeric,
email = Optional(String)
))
# Use it
person %:% Person %<-% list(
name = "Alice",
age = 30,
email = "alice@example.com"
)
# Missing required field fails
person <- list(name = "Bob")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]
# Extra fields not allowed fail
person <- list(
name = "Charlie",
age = 25,
email = "charlie@example.com",
extra = "not allowed"
)Error: Type error: Expected {name: string, age: numeric, email?: string | null}, got list
Details: Unexpected field: 'extra' (valid fields: name, age, email)
Received: list with fields: [name, age, email, extra]
Data Frame Types
Define schemas for data frames:
# Define a user table schema
UserTable <- create_dataframe_type(list(
id = Integer,
username = String,
active = Bool,
last_login = Optional(String)
))
# Valid data frame
users %:% UserTable %<-% data.frame(
id = 1:3,
username = c("alice", "bob", "charlie"),
active = c(TRUE, FALSE, TRUE)
)
# Missing required column fails
users <- data.frame(
username = c("alice", "bob"),
active = c(TRUE, FALSE)
)Error: Type error: Expected data.frame{id: integer, username: string, active: bool, last_login?: string | null}, got data.frame[2 x 2]
Details: Missing required column(s): id (expected columns: id, username, active)
users <- data.frame(
id = 1:2,
username = c("alice", "bob"),
active = c("yes", "no") # Should be logical
)Error: Type error in 'active': Expected bool, got string of length 2
Received: [yes, no]
Homogeneous List Types
For lists of similar items (like API responses):
# Define item structure
TodoItem <- create_list_type(list(
userId = Numeric,
id = Numeric,
title = String,
completed = Bool
))
# List of todo items
TodoList <- ListOf(TodoItem)
# Valid list
todos %:% TodoList %<-% list(
list(userId = 1, id = 1, title = "Task 1", completed = FALSE),
list(userId = 1, id = 2, title = "Task 2", completed = TRUE)
)
todos <- list(
list(userId = 1, id = 1, title = "Task 1", completed = FALSE),
list(wrong = "structure")
)Error: Type error in 'todos': Expected list<{userId: numeric, id: numeric, title: string, completed: bool}>, got list of length 2
Received: list of length 2
Combining Features
All features can be combined:
# Complex nested structure
Company <- create_list_type(list(
name = String,
employees = ListOf(create_list_type(list(
name = String,
salary = Numeric,
department = Optional(String)
))),
founded = Numeric[3] # [year, month, day]
))
company %:% Company %<-% list(
name = "Tech Corp",
employees = list(
list(name = "Alice", salary = 75000, department = "Engineering"),
list(name = "Bob", salary = 65000) # department optional
),
founded = c(2020, 1, 15)
)Advanced Usage
Typed Functions
Use typed_function() to wrap any function with runtime type checks on its parameters and, optionally, its return value:
# Basic typed function with parameter and return type checking
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: a
# Optional parameter — title may be a String or NULL
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 — id can be String or Numeric
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: TRUE
Custom Types
Create your own types using create_type():
# Positive number type
Positive <- create_type("positive", function(x) {
is.numeric(x) && all(x > 0)
})
value %:% Positive %<-% 5
value <- -1Error: Type error in 'value': Expected positive, got double
Received: -1
Use Enum(...) when you only need membership in a known set of values. Use create_type() when the rule depends on arbitrary logic.
Type Composition
Build complex types from simpler ones:
# Email type
Email <- create_type("email", function(x) {
is.character(x) && length(x) == 1 &&
grepl("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", x)
})
# Person with email
PersonWithEmail <- create_list_type(list(
name = String,
email = Email,
age = Numeric
))
person %:% PersonWithEmail %<-% list(
name = "Alice",
email = "alice@example.com",
age = 30
)
person %:% PersonWithEmail %<-% list(
name = "Bob",
email = "invalid-email",
age = 25
)Error: Type error in 'email': Expected email, got string
Received: invalid-email
Error Messages
sicher provides clear, contextual error messages:
# Type mismatch
x %:% Numeric %<-% "not a number"Error: Type error in 'x': Expected numeric, got string
Received: not a number
# Missing required field
PersonType <- create_list_type(list(name = String, age = Numeric))
p %:% PersonType %<-% list(name = "Alice")Error: Type error: Expected {name: string, age: numeric}, got list
Details: Missing required field(s): age (expected fields: name, age)
Received: list with fields: [name]
# Wrong vector length
vec %:% Numeric[3] %<-% c(1, 2)Error: Type error in 'vec': Expected numeric[3], got double of length 2
Received: [1, 2]