What is sicher?

sicher (German for safe or certain and pronounced zeesher) is an R package that brings type safety to R programming. It allows you to define types for your variables and enforce them at runtime, catching type errors early and making your code more robust and maintainable.

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

Key Features

  • Runtime Type Checking: Catch type errors during development
  • Intuitive Syntax: Use familiar operators like %:% and %<-%
  • Rich Type System: Support for primitives, lists, data frames, unions, and more
  • Schema Extension: Build larger structured list types from smaller reusable ones with extend()
  • Enum Types: Restrict variables to a fixed set of allowed values with Enum(...)
  • Literal Types: Model exact scalar literals with Literal(...)
  • Typed Functions: Enforce parameter and return types on any function with typed_function()
  • Constraint Modifiers: NonNA(), Between(), Matches(), NonEmpty(), MinLength(), MaxLength(), Unique() and Refine() for value-level constraints
  • Advanced Type Shapes: Intersections (&), tuples, records, discriminated unions, recursive, branded and function types
  • Object Utilities: Derive schemas with Pick(), Omit(), Partial(), Required() and KeyOf()
  • Data Validation: Check data without typed variables using is_type(), parse_as(), safe_parse() and validate_json(), and export types with to_json_schema()
  • Transforming Types: Fill in defaults with Default() and convert values with Coerce()
  • Field Aliases: Read camelCase (or any other) data into snake_case fields with Alias() and the alias argument, and write it back with to_json() and dump_as()
  • Self-documenting Schemas: Add descriptions and examples with Describe() and export them with to_json_schema()
  • Settings From the Environment: Read, convert and check configuration from environment variables with parse_env()
  • Detailed Errors: Every problem is reported at once, with its location (e.g. todos[[2]]$title)
  • Zero Runtime Overhead: Types are checked only during assignment
  • Gradual Adoption: Add types incrementally to existing code

Quick Start

Want to try it first? The Playground runs sicher in your browser, no installation needed.

Install the package and start using types immediately:

library(sicher)

# Basic type checking
x %:% Numeric %<-% 42
x <- "text"  # Error!
Error: Type error in 'x': Expected numeric, got string
Received: text

Why Type Safety in R?

R is a dynamically typed language, which offers great flexibility but can lead to:

  • Runtime errors from unexpected data types
  • Bugs that only appear with certain inputs
  • Maintenance difficulties in large codebases
  • API confusion when functions receive wrong types

sicher addresses these issues by providing:

  • Early error detection during development
  • Self-documenting code through type annotations
  • Safer refactoring with type guarantees

Real-World Use Cases

Example 1:

Suppose you write a function that calculates the average salary payout for employees. In a real company workflow, the data might come from, a CSV export from HR, manual editing in Excel, a database export and so on. Sometimes the numbers that are saved within those data sources can have invalid types (string for example):

calculate_mean_payroll <- function(salaries) {
  average <- mean(salaries)
  return(average)
}

If the inputs have a correct data type, then we don’t have any issue:

calculate_mean_payroll(c(1800, 2300, 4000, 1222))
[1] 2330.5

Now, imagine that our input as following:

calculate_mean_payroll(c(1800, "2300", 4000, 1222))
Warning in mean.default(salaries): argument is not numeric or logical:
returning NA
[1] NA

Instead of getting an error, we get a warning and an NA value as a result. This is a very simple function but in a realistic example, you might have a script that uses thousands of lines of code and many functions to obtain a specific result, thus making this kind of debugging harder. We definitely expect more robustness from R.

Using sicher we can make sure that our data types remain valid and as we defined them.

calculate_mean_payroll_safe <- function(salaries) {
  salaries %:% Numeric %<-% salaries
  average <- mean(salaries)
  return(average)
}
calculate_mean_payroll_safe(c(1800, "2300", 4000, 1222))
Error: Type error in 'salaries': Expected numeric, got string of length 4
Received: [1800, 2300, 4000, 1222]

Note that when you define a type for a specific variable, that type is guarded throughout the execution of your code:

calculate_mean_payroll_safe <- function(salaries) {
  salaries %:% Numeric %<-% salaries
  average <- mean(salaries)
  salaries <- "my salaries" # changing the type of the variable to string
  return(average)
}
calculate_mean_payroll_safe(c(1800, "2300", 4000, 1222))
Error: Type error in 'salaries': Expected numeric, got string of length 4
Received: [1800, 2300, 4000, 1222]

Example 2:

When working with Rest APIs, you want make sure that the data that you’re getting has a specific shape and type, consider the following API: https://jsonplaceholder.typicode.com/todos/1

It provides us with one object of the shape:

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

Using sicher you can, similar to the pydantic Python framework, define the expected structure of the response:

ToDo <- create_list_type(
  type_spec = list(
    userId = Numeric,
    id = Numeric,
    title = String,
    completed = Bool
  )
)

ToDo
<type: {userId: numeric, id: numeric, title: string, completed: bool} >

When an API response grows over time, you can extend the shared base schema instead of duplicating it:

TimedToDo <- extend(ToDo, list(
  createdAt = String,
  tags = Optional(ListOf(String))
))

TimedToDo
<type: {userId: numeric, id: numeric, title: string, completed: bool, createdAt: string, tags?: list<string> | null} >

Then when fetching the data, you can safely check that the response corresponds to the expected object:

api_url <- "https://jsonplaceholder.typicode.com/todos/1"

response %:% ToDo %<-% httr::content(httr::GET(api_url))

response
$userId
[1] 1

$id
[1] 1

$title
[1] "delectus aut autem"

$completed
[1] FALSE

If the API for whatever reason started to produce invalid responses, sicher would catch it immediately:

invalid_response <- list(
  userId = 1, 
  id = 1, 
  title = "delectus aut autem", 
  completed = "yes" # Invalid! 
)

response <- invalid_response
Error: Type error in 'response$completed': Expected bool, got string
Received: yes
invalid_response <- list(
  userId = 1, 
  id = 1, 
  title = "delectus aut autem", 
  new_name_not_recognized = TRUE # Invalid! 
)

response <- invalid_response
Error: 2 type errors found:
  * Type error in 'response$completed': Missing required field (expected bool)
  * Type error in 'response$new_name_not_recognized': Unexpected field (valid fields: userId, id, title, completed)

The above URL returns only 1 object back, if we use the following URL https://jsonplaceholder.typicode.com/todos/ we get several (200) json objects back:

api_url <- "https://jsonplaceholder.typicode.com/todos/"

full_response <- httr::content(httr::GET(api_url))

length(full_response)
[1] 200
full_response[[1]]
$userId
[1] 1

$id
[1] 1

$title
[1] "delectus aut autem"

$completed
[1] FALSE

Now how to set the type of a list of lists? using the ListOf function:

ToDos <- ListOf(ToDo)

full_response %:% ToDos %<-% httr::content(httr::GET(api_url))

Now if we try to change the type of one element of the lists:

invalid_list <- full_response[[1]]$userId <- "A string" # Invalid type
Error: Type error in 'full_response[[1]]$userId': Expected numeric, got string
Received: A string

Data Frame Schemas

# Define data frame structure
SalesData <- create_dataframe_type(
  col_spec = list(
    date = String,
    product = String,
    quantity = Integer,
    price = Numeric,
    region = String
  ))

sales_df <- data.frame(
  date = c("2024-01-01", "2024-01-02"),
  product = c("Widget A", "Widget B"),
  quantity = c(10, 5),
  price = c(29.99, 49.99),
  region = c("North", "South")
)

validated_sales %:% SalesData %<-% sales_df
Error: Type error in 'validated_sales$quantity': Expected integer, got double of length 2
Received: [10, 5]
sales_df <- data.frame(
  date = c("2024-01-01", "2024-01-02"),
  product = c("Widget A", "Widget B"),
  quantity = c(10L, 5L),
  price = c(29.99, 49.99),
  region = c("North", "South")
)

validated_sales %:% SalesData %<-% sales_df

Type System Overview

Built-in Types

  • Primitives: Integer, Double, Numeric, String, Bool
  • Containers: List, DataFrame, Function, Environment
  • Dates and factors: Date, DateTime, Factor(levels)
  • Numeric rules: Positive, NonNegative, Whole
  • Special: Any, Null, InstanceOf(class), MatrixOf(type, nrow, ncol)

Enum Types

Use Enum(...) when a value must belong to a closed set:

status %:% Enum("draft", "published", "archived") %<-% "draft"
priority %:% Enum(1, 2, 3) %<-% 2

Literal Types

Use Literal(...) when you want exact scalar values:

direction %:% Literal("left", "right") %<-% "left"
http_status %:% Literal(200, 404) %<-% 200

Type Modifiers

  • Scalar: Single element only
  • Readonly: Cannot be reassigned
  • Optional: Allows NULL values
  • NonNA: Rejects any value containing NA
  • Between(min, max): Numeric closed-interval range constraint
  • Matches(pattern): Regex-constrained character vectors
  • NonEmpty: Rejects zero-length vectors, lists, and zero-row data frames
  • Nullable: Field must be present but may be NULL
  • MinLength(type, n) / MaxLength(type, n): Bound the number of elements
  • Unique: Rejects duplicated elements
  • Refine(type, predicate, message): Adds a custom rule
  • Default(type, value): Replaces NULL or a missing field with a default
  • Coerce(type): Converts values (e.g. "42" to 42) before checking

Advanced Types

  • Unions: TypeA | TypeB
  • Literals: Literal("left", "right") or Literal(200, 404)
  • Enums: Enum("a", "b") or Enum(c("a", "b"))
  • Size constraints: Numeric[3] (exactly 3 elements)
  • List types: Structured lists with field specifications
  • Data frame types: Column type specifications
  • Homogeneous lists: ListOf(ElementType)
  • Typed functions: typed_function(fn, params, .return)
  • Intersections: TypeA & TypeB
  • Tuples and records: Tuple(String, Numeric), Record(String, Numeric)
  • Tagged unions: DiscriminatedUnion("kind", circle = Circle, square = Square)
  • Recursive types: Lazy(function() Tree)
  • Branded types: Brand(String, "UserId") with brand()
  • Function signatures: FunctionOf(list(x = Numeric), .return = String)
  • Object utilities: Pick(), Omit(), Partial(), Required(), KeyOf()
  • Validation: is_type(), parse_as(), safe_parse(), validate_json(), to_json_schema()
  • Typed variable helpers: type_of(x) returns a variable’s declared type, untype(x) removes it

Getting Started

Installation

Currently, install from Github with:

devtools::install_github("feddelegrand7/sicher")

Basic Usage

# 1. Annotate variables with types
name %:% String %<-% "Alice"
age %:% Numeric %<-% 30

# 2. Create custom types
PositiveNumber <- create_type("positive number", 
  function(x) is.numeric(x) && all(x > 0))

value %:% PositiveNumber %<-% 42

# 3. Use type modifiers
single_value %:% Scalar(String) %<-% "hello"
constant %:% Readonly(Numeric) %<-% 3.14
maybe_value %:% Optional(String) %<-% NULL

# 3b. Restrict values with enums
status %:% Enum("draft", "published", "archived") %<-% "draft"

# 3c. Literal values
direction %:% Literal("left", "right") %<-% "left"

# 4. Create structured types
Person <- create_list_type(list(
  name = String,
  age = Numeric,
  email = Optional(String)
))

person %:% Person %<-% list(
  name = "Bob",
  age = 25,
  email = "bob@example.com"
)

extend() composes new structured list types from an existing one. This is useful when several objects share a common core shape:

Employee <- extend(Person, list(
  role = String,
  manager = Optional(String)
))

employee %:% Employee %<-% list(
  name = "Alice",
  age = 30,
  email = "alice@example.com",
  role = "Engineer"
)

If a field in extra has the same name as one in the base type, the new definition wins and extend() warns so the override is not silent.

Validating Data Without Typed Variables

safe_parse() checks a value and returns every problem instead of throwing, which is handy for validating input such as API responses:

Todo <- create_list_type(list(id = Numeric, title = String, done = Bool))

result <- safe_parse(
  list(list(id = 1, title = "Buy milk", done = FALSE), list(id = "2", title = 3)),
  ListOf(Todo)
)
result$success
[1] FALSE
result$issues
         path                                message expected     got
1    [[2]]$id           Expected numeric, got string  numeric  string
2 [[2]]$title            Expected string, got double   string  double
3  [[2]]$done Missing required field (expected bool)     bool missing
           code
1  invalid_type
2  invalid_type
3 missing_field

Data In, Data Out

The same type can read external data, write it back and configure the app. Field names are converted with alias, and to_json() checks the value before writing it:

Todo <- create_list_type(list(
  todo_id = Scalar(Integer),
  title   = Describe(Scalar(String), "What needs doing"),
  done    = Default(Scalar(Bool), FALSE)
), alias = "camel")

todo <- validate_json('{"todoId": 1, "title": "Buy milk"}', Todo)
todo$done
[1] FALSE
cat(to_json(todo, Todo))
{"todoId":1,"title":"Buy milk","done":false}
Config <- create_list_type(list(port = Default(Scalar(Integer), 8080L)))
parse_env(Config, prefix = "TODO_", env = c(TODO_PORT = "3000"))
$port
[1] 3000

Error Examples

See how sicher catches type errors:

# Wrong type assignment
x %:% Numeric %<-% "text"
Error: Type error in 'x': Expected numeric, got string
Received: text
# Missing required fields
User <- create_list_type(list(name = String, age = Numeric))
user %:% User %<-% list(name = "Alice")  # Missing age
Error: Type error in 'user$age': Missing required field (expected numeric)
# Wrong data frame column types
Table <- create_dataframe_type(list(id = Integer, name = String))
df %:% Table %<-% data.frame(id = c("1", "2"), name = c("A", "B"))  # id should be integer
Error: Type error in 'df$id': Expected integer, got string of length 2
Received: [1, 2]

Global Options

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.mode

Controls how typed assignments (%:% and %<-%) behave.

Values

  • "on" (default)
    Enables full runtime type enforcement.
    • Typed variables are implemented as active bindings
    • All assignments are validated against their declared type
    • Type violations result in immediate errors
  • "warn"
    Reports type violations as warnings and keeps the assigned value.
    • Useful when adding sicher to existing code
    • Applies to typed variables and typed functions
    • Readonly() is still enforced
  • "off"
    Disables the type system entirely.
    • Typed annotations are ignored
    • Assignments behave like standard R assignments (<-)
    • No validation is performed
    • No active bindings are created

How to set options

You can configure the mode globally, for example, disabling strict type checking with:

options(sicher.mode = "off")

Report type violations as warnings while keeping the assigned value with:

options(sicher.mode = "warn")

Or enable strict typing with:

options(sicher.mode = "on") # this is the default

When to use each mode

  • "on" (default) (strict mode) Use in situations where correctness and safety are important:
    • Package development
    • Data validation pipelines
    • Production systems where type guarantees are desired
    • APIs or functions expecting structured inputs In this mode, sicher actively enforces types at runtime and prevents invalid assignments.
  • "warn" (gradual adoption mode) Use when adding sicher to existing code:
    • Find type violations in a working codebase without breaking it
    • Log problems in production while you fix them In this mode, violations are reported as warnings and the assigned value is kept. Readonly() is still enforced.
  • "off" (disabled mode) Use when you want to bypass the type system entirely:
    • Performance-sensitive code where validation overhead is not desired
    • Debugging or testing environments where strict typing is temporarily unnecessary
    • Running code in environments where active bindings may interfere with other tools or workflows
    • Interoperability with code that assumes standard R assignment semantics In this mode, typed annotations are effectively ignored, and variables behave like regular R objects.

Example

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: string
options(sicher.mode = "off")
my_var_y %:% Numeric %<-% 10 
my_var_y <- "string" # nothing happens

It can also be temporarily scoped:

withr::with_options(
  list(sicher.mode = "off"),
  {
    my_var_y %:% Numeric %<-% 10
    my_var_y <- "string"
  }
)