Features

Core Features

Built-in Types

sicher provides a comprehensive set of built-in types:

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])

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 fails
Error: 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.0
Error: 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 fails
Error: 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_   # Error
Error: 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) %<-% 42L

Matches — 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 pattern
Error: 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 match
Error: 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 > 0
Error: Type error: expected non_empty<matches("^[a-z]+$")> (length > 0), but got an empty value
tags <- c("foo", "BAR")   # Error: "BAR" does not match
Error: 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 > 0
Error: Type error: expected non_empty<string> (length > 0), but got an empty value
items %:% NonEmpty(List) %<-% list(1, 2, 3)
items <- list()   # Error: length > 0
Error: 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 > 0
Error: 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 short
Error: 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 <- TRUE
Error: 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) %<-% 2
status <- "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 <- -1
Error: 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]