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 in 'salary': expected non_na<numeric> (no NA values), but value contains NA(s) (at position 2)

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 in 'label': 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 in 'age': 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 in 'score': expected between[0, 1], but value [1.5] is outside [0, 1] (at position 2)

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 in 'email': 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 in 'codes': expected matches("^[A-Z]{3}$"), but value ["us"] does not match the pattern (at position 2)

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 in 'tags': 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 in 'tags': expected matches("^[a-z]+$"), but value ["BAR"] does not match the pattern (at position 2)

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 in 'tags': 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 in 'items': 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 in 'valid_df': 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 in 'person$age': Missing required field (expected numeric)
# Extra fields not allowed fail
person <- list(
    name = "Charlie",
    age = 25,
    email = "charlie@example.com",
    extra = "not allowed"
)
Error: Type error in 'person$extra': Unexpected field (valid fields: name, age, email)

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 in 'users$id': Missing required column (expected integer)
users <- data.frame(
  id = 1:2,
  username = c("alice", "bob"),
  active = c("yes", "no")  # Should be logical
)
Error: Type error in 'users$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: 5 type errors found:
  * Type error in 'todos[[2]]$userId': Missing required field (expected numeric)
  * Type error in 'todos[[2]]$id': Missing required field (expected numeric)
  * Type error in 'todos[[2]]$title': Missing required field (expected string)
  * Type error in 'todos[[2]]$completed': Missing required field (expected bool)
  * Type error in 'todos[[2]]$wrong': Unexpected field (valid fields: userId, id, title, completed)

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():

# Even number type
Even <- create_type("even", function(x) {
  is.numeric(x) && all(x %% 2 == 0)
})

value %:% Even %<-% 4

value <- 3
Error: Type error in 'value': Expected even, got double
Received: 3

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 'person$email': Expected email, got string
Received: invalid-email

More Built-in Types

start   %:% Date        %<-% as.Date("2024-01-01")
created %:% DateTime    %<-% Sys.time()
cache   %:% Environment %<-% new.env()

price %:% Positive    %<-% 9.99
count %:% NonNegative %<-% 0
n     %:% Whole       %<-% 3      # 3 and 3L are fine, 3.5 is not
n <- 2.5
Error: Type error in 'n': expected whole, but value [2.5] is not a whole number
size  %:% Factor(c("S", "M", "L")) %<-% factor("M", levels = c("S", "M", "L"))
m     %:% MatrixOf(Numeric, nrow = 2, ncol = 2) %<-% diag(2)
model %:% InstanceOf("lm") %<-% lm(mpg ~ wt, data = mtcars)

More Modifiers

MinLength, MaxLength and Unique

Lengths are counted with length() (nrow() for data frames), like NonEmpty() and [n].

top3 %:% MaxLength(Numeric, 3) %<-% c(9, 8, 7)
top3 <- c(9, 8, 7, 6)
Error: Type error in 'top3': expected max_length<numeric, 3> (at most 3 element(s)), but got 4
ids %:% Unique(Integer) %<-% c(1L, 2L, 3L)
ids <- c(1L, 2L, 2L)
Error: Type error in 'ids': expected unique<integer>, but found duplicated value(s): [2] (at position 3)

Refine

Add a custom rule on top of an existing type:

Password <- Refine(
  Scalar(String),
  function(x) nchar(x) >= 8,
  message = "must be at least 8 characters"
)

pw %:% Password %<-% "correct horse battery"
pw <- "short"
Error: Type error in 'pw': must be at least 8 characters
Received: short

Rules Across Fields

Refine() reports one problem about the whole value. Check() runs a rule that can report any number of problems, each at the field it is about, with ctx$issue(message, path):

Booking <- Check(
  create_list_type(list(start = Date, end = Date, adults = Integer, kids = Integer)),
  function(x, ctx) {
    if (x$end < x$start) ctx$issue("must be after start", path = "end")
    if (x$adults == 0L && x$kids > 0L) ctx$issue("children need an adult", path = "kids")
  }
)

res <- safe_parse(
  list(start = as.Date("2024-05-10"), end = as.Date("2024-05-01"), adults = 0L, kids = 2L),
  Booking
)
res$issues[, c("path", "message")]
  path                message
1  end    must be after start
2 kids children need an adult

The path can point into nested values, e.g. path = c("address", "zip") or path = list("items", 2, "qty").

Nullable

Optional() fields may be left out of a list; Nullable() fields must be present but may be NULL:

Profile <- create_list_type(list(
  name     = String,
  nickname = Optional(String),
  bio      = Nullable(String)
))

profile %:% Profile %<-% list(name = "Ann", bio = NULL)
profile <- list(name = "Ann")
Error: Type error in 'profile$bio': Missing required field (expected string | null)

Transforming Values

Default() and Coerce() change the value as well as checking it. Typed variables, typed function arguments and parse_as() all keep the transformed value.

retries %:% Default(Integer, 3L) %<-% NULL
retries
[1] 3
Config <- create_list_type(list(host = String, port = Default(Numeric, 8080)))
parse_as(list(host = "localhost"), Config)
$host
[1] "localhost"

$port
[1] 8080
age %:% Coerce(Numeric) %<-% "42"
age
[1] 42
age <- "forty"   # cannot be converted: an error, not a silent NA
Error: Type error in 'age': cannot convert ["forty"] to numeric

List and data frame types can also accept or remove unknown fields with extra = "allow" or extra = "drop":

Stripped <- create_list_type(list(name = String), extra = "drop")
parse_as(list(name = "Ann", internal_id = 7), Stripped)
$name
[1] "Ann"

Transform and Pipe

Transform(type, fn) checks a value, then converts it with fn. Unlike Coerce(), which only converts values that fail the check, it always runs. Give output to check the converted value too. Pipe() chains types, passing each parsed value to the next and stopping at the first one that fails:

Email <- Transform(Scalar(String), function(x) tolower(trimws(x)))
parse_as("  Ada@Example.COM ", Email)
[1] "ada@example.com"
Length <- Transform(String, nchar, output = Integer)
parse_as("hello", Length)
[1] 5
Share <- Pipe(Coerce(Numeric), Transform(Numeric, function(x) x / 100), Between(0, 1))
parse_as("42", Share)
[1] 0.42
parse_as("420", Share)
Error: Type error: expected between[0, 1], but value [4.2] is outside [0, 1]

In a list type, a Transform(Optional(type), fn) field may be left out. To give a transformed field a default, put Default() outside: Default(Transform(String, toupper), "A"). to_json_schema() describes what the type accepts, before the transform.

More Type Shapes

Intersections

Named <- create_list_type(list(name = String))
Aged  <- create_list_type(list(age = Numeric))

someone %:% (Named & Aged) %<-% list(name = "Ann", age = 31)

Percent <- Numeric & Between(0, 100)
check_type(150, Percent)
Error: Type error: expected between[0, 100], but value [150] is outside [0, 100]

Tuples and Records

point %:% Tuple(Numeric, Numeric) %<-% list(1.5, 2)
point <- list(1.5, "2")
Error: Type error in 'point[[2]]': Expected numeric, got string
Received: 2
scores %:% Record(String, Numeric) %<-% list(alice = 90, bob = 85)
flags  %:% Record(Enum("debug", "verbose"), Bool) %<-% list(debug = TRUE)
flags <- list(debug = TRUE, color = FALSE)
Error: Type error in 'flags$color': Invalid key (expected enum["debug", "verbose"])

Discriminated Unions

Circle <- create_list_type(list(radius = Numeric))
Square <- create_list_type(list(side = Numeric))
Shape  <- DiscriminatedUnion("kind", circle = Circle, square = Square)

shape %:% Shape %<-% list(kind = "circle", radius = 2)
shape <- list(kind = "square", radius = 3)
Error: 2 type errors found:
  * Type error in 'shape$side': Missing required field (expected numeric)
  * Type error in 'shape$radius': Unexpected field (valid fields: kind, side)

Exhaustive Matching

match_type() builds a function that checks a value and calls the handler for its case. The handlers are checked when the matcher is created: a forgotten case is an error right away, not on the one call that reaches it, and so is a handler for a case that does not exist.

area <- match_type(Shape,
  circle = function(s) pi * s$radius^2,
  square = function(s) s$side^2
)
area(list(kind = "square", side = 3))
[1] 9
match_type(Shape, circle = function(s) pi * s$radius^2)
Error: match_type() is not exhaustive: no handler for 'square'. Add a handler for each case or a .default handler
match_type(Shape, circle = function(s) 1, sqaure = function(s) 2)
Error: match_type() has handlers for cases that discriminated<kind: "circle" | "square"> does not have: 'sqaure' (did you mean 'square'?). Its cases are 'circle', 'square'

Besides DiscriminatedUnion() tags, the cases can be the values of an Enum() or Literal(), the levels of a Factor(levels = ...), or the members of a union, named by their type names. .default handles the cases without their own handler, and extra arguments are passed to the handler:

status_label <- match_type(Enum("active", "paused", "closed"),
  active = function(x) "Running",
  .default = function(x) "Not running"
)
status_label("paused")
[1] "Not running"
describe <- match_type(Integer | String | Null,
  integer = function(x) "a number",
  string  = function(x) "some text",
  null    = function(x) "nothing"
)
describe(NULL)
[1] "nothing"

Recursive Types

Tree <- create_list_type(list(
  value    = Numeric,
  children = ListOf(Lazy(function() Tree, name = "Tree"))
))

tree %:% Tree %<-% list(value = 1, children = list(
  list(value = 2, children = list())
))

Branded Types

UserId  <- Brand(Scalar(String), "UserId")
OrderId <- Brand(Scalar(String), "OrderId")

current_user %:% UserId %<-% brand("u-123", UserId)
current_user <- brand("o-789", OrderId)
Error: Type error in 'current_user': Expected UserId<scalar<string>>, got a value branded as OrderId. Create branded values with brand(value, <type>)
Received: o-789

Function Types

Formatter <- FunctionOf(list(x = Numeric), .return = String)

fmt %:% Formatter %<-% function(x) format(x, nsmall = 2)
fmt(3)
[1] "3.00"
fmt("a")
Error: Type error in 'x': Expected numeric, got string
Received: a

Objects With Methods

InstanceOf() checks the class. HasMethods() checks what an object can do: it accepts any object with methods for the given generics, so one type covers lm, glm and model classes from other packages. S3 methods (other than default methods), S4 methods and the function fields of R6 objects count.

Model <- HasMethods("predict", "residuals")

fit_summary <- typed_function(
  function(model) summary(residuals(model)),
  params = list(model = Model)
)
fit_summary(lm(mpg ~ wt, data = mtcars))
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
-4.5432 -2.3647 -0.1252  0.0000  1.4096  6.8727 
fit_summary(list(coefficients = 1))
Error: Type error in 'model': Expected has_methods[predict, residuals], but class 'list' has no predict() or residuals() method
Received: list with fields: [coefficients]

Generic Types

A generic type is a function that takes types and returns a type:

Paginated <- function(Item) {
  create_list_type(list(items = ListOf(Item), total = Scalar(Integer)))
}

page %:% Paginated(Numeric) %<-% list(items = list(1, 2), total = 2L)

Object Utilities

User <- create_list_type(list(
  id    = Integer,
  name  = String,
  email = Optional(String)
))

Pick(User, "id", "name")
<type: {id: integer, name: string} >
Omit(User, "id")
<type: {name: string, email?: string | null} >
Partial(User)
<type: {id?: integer | null, name?: string | null, email?: string | null} >
Required(User)
<type: {id: integer, name: string, email: string} >
KeyOf(User)
<type: enum["id", "name", "email"] >
patch %:% Partial(Omit(User, "id")) %<-% list(email = "new@example.com")

Validating Data

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

data <- list(
  list(id = 1, title = "Buy milk", done = FALSE),
  list(id = "2", title = 3)
)

is_type(data, ListOf(TodoItem))
[1] FALSE
result <- safe_parse(data, ListOf(TodoItem))
result
<parse result: 3 issues>
  * [[2]]$id: Expected numeric, got string
  * [[2]]$title: Expected string, got double
  * [[2]]$done: Missing required field (expected bool)
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 index
1  invalid_type      
2  invalid_type      
3 missing_field      
parse_as(data, ListOf(TodoItem), context = "todos")
Error: 3 type errors found:
  * Type error in 'todos[[2]]$id': Expected numeric, got string
  * Type error in 'todos[[2]]$title': Expected string, got double
  * Type error in 'todos[[2]]$done': Missing required field (expected bool)

JSON

Order <- create_list_type(list(
  id    = Scalar(Integer),
  items = ListOf(create_list_type(list(sku = Scalar(String), qty = Scalar(Whole))))
))

validate_json('{"id": 7, "items": [{"sku": "A1", "qty": 2}]}', Order)
$id
[1] 7

$items
$items[[1]]
$items[[1]]$sku
[1] "A1"

$items[[1]]$qty
[1] 2
validate_json('{"id": "7", "items": [{"sku": "A1"}]}', Order)
Error: 2 type errors found:
  * Type error in 'id': Expected scalar<integer>, got string
  * Type error in 'items[[1]]$qty': Missing required field (expected scalar<whole>)
cat(to_json_schema(Order, json = TRUE))
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": {
      "type": "integer"
    },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "sku": {
            "type": "string"
          },
          "qty": {
            "type": "integer"
          }
        },
        "required": [
          "sku",
          "qty"
        ],
        "additionalProperties": false
      }
    }
  },
  "required": [
    "id",
    "items"
  ],
  "additionalProperties": false
}

Field Aliases

Data from other systems rarely follows R naming. Alias() gives a field an external name, and the alias argument of create_list_type() and create_dataframe_type() renames every field with a convention ("camel", "pascal", "snake", "kebab") or a function. Values are read from the external name and stored under the R name:

Customer <- create_list_type(list(
  customer_id = Scalar(Integer),
  full_name   = Alias(Scalar(String), "name"),   # explicit alias wins
  signed_up   = Coerce(Scalar(Date)),
  newsletter  = Default(Scalar(Bool), FALSE)
), alias = "camel")

customer <- validate_json(
  '{"customerId": 42, "name": "Ann Lee", "signedUp": "2024-03-01"}',
  Customer
)
str(customer)
List of 4
 $ customer_id: int 42
 $ full_name  : chr "Ann Lee"
 $ signed_up  : Date[1:1], format: "2024-03-01"
 $ newsletter : logi FALSE

The R name is accepted as well, so values built in R parse too. Errors point at the external names, because those are the keys in the data:

validate_json('{"customerId": "42", "signedUp": "yesterday", "fullName": "Ann"}', Customer)
Error: 4 type errors found:
  * Type error in 'customerId': Expected scalar<integer>, got string
  * Type error in 'name': Missing required field (expected scalar<string>)
  * Type error in 'signedUp': cannot convert value to scalar<date>: character string is not in a standard unambiguous format
  * Type error in 'fullName': Unexpected field (valid fields: customerId, name, signedUp, newsletter)

Aliases survive extend(), Pick(), Omit(), Partial(), Required() and &, and data frame columns can be aliased the same way:

Survey <- create_dataframe_type(list(
  respondent = String,
  score      = Between(0, 10)
), alias = function(name) paste0("Q_", toupper(name)))

parse_as(data.frame(Q_RESPONDENT = c("a", "b"), Q_SCORE = c(7, 9)), Survey)
  respondent score
1          a     7
2          b     9

Writing JSON

to_json() is the reverse of validate_json(), and dump_as() is the reverse of parse_as(). Both check the value first, so you can never send data that breaks its own schema, then write fields under their aliases:

cat(to_json(customer, Customer))
{"customerId":42,"name":"Ann Lee","signedUp":"2024-03-01","newsletter":false}
# R list with the external names, e.g. for a plumber endpoint
str(dump_as(customer, Customer))
List of 4
 $ customerId: int 42
 $ name      : chr "Ann Lee"
 $ signedUp  : Date[1:1], format: "2024-03-01"
 $ newsletter: logi FALSE
# Values that do not match the type are rejected
broken <- customer
broken$newsletter <- "yes"
to_json(broken, Customer)
Error: Type error in 'newsletter': Expected scalar<bool> = FALSE, got string
Received: yes

The type decides the shape of the JSON, not the data. Scalar() fields are written as JSON scalars and other vectors as arrays, even with a single element, so clients always get the same shape:

Post <- create_list_type(list(
  post_id = Scalar(Integer),
  tags    = String,
  draft   = Optional(Scalar(Bool))
), alias = "camel")

post <- list(post_id = 1L, tags = "r", draft = NULL)

cat(to_json(post, Post))
{"postId":1,"tags":["r"],"draft":null}
cat(to_json(post, Post, exclude_null = TRUE))
{"postId":1,"tags":["r"]}
# The round trip gives back the same value
identical(validate_json(to_json(post, Post), Post), parse_as(post, Post))
[1] TRUE

NULL and NA are written as null, and dates and date-times use ISO 8601. Extra arguments go to jsonlite::toJSON(), e.g. to_json(post, Post, pretty = TRUE).

Describing Types

Describe() attaches a description, a title and examples to any type, including whole list types. They do not change how values are checked, but to_json_schema() writes them out. The resulting schema can serve as API documentation, or as the input schema of a tool for a language model:

WeatherArgs <- Describe(create_list_type(list(
  city = Describe(Scalar(String), "City name", examples = c("Paris", "Oslo")),
  unit = Describe(
    Default(Scalar(Enum("celsius", "fahrenheit")), "celsius"),
    "Temperature unit"
  )
)), "Arguments of the get_weather tool")

WeatherArgs
<type: {city: scalar<string>, unit?: scalar<enum["celsius", "fahrenheit"]> = "celsius"} >
  description: Arguments of the get_weather tool 
cat(to_json_schema(WeatherArgs, json = TRUE))
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "city": {
      "type": "string",
      "description": "City name",
      "examples": [
        "Paris",
        "Oslo"
      ]
    },
    "unit": {
      "enum": [
        "celsius",
        "fahrenheit"
      ],
      "default": "celsius",
      "description": "Temperature unit"
    }
  },
  "required": [
    "city"
  ],
  "additionalProperties": false,
  "description": "Arguments of the get_weather tool"
}

Examples are checked against the type, so the documentation cannot drift from the schema:

Describe(Scalar(String), "City name", examples = list(75001))
Error: Type error in 'example 1': Expected scalar<string>, got double
Received: 75001

Inspecting Typed Variables

age_years %:% Numeric %<-% 30
type_of(age_years)
<type: numeric >
untype(age_years)
age_years <- "no longer checked"

Settings From Environment Variables

Apps usually read their configuration from environment variables, which are always text. parse_env() reads one variable per field of a list type, converts the text to the field’s type and checks it. The variable for a field is the prefix plus the field name in upper case; a field with an Alias() is read from the alias as is, which is handy for standard names such as DATABASE_URL:

Settings <- create_list_type(list(
  db_url  = Alias(Scalar(Matches("^postgres://")), "DATABASE_URL"),
  port    = Default(Scalar(Integer), 8080L),
  debug   = Default(Scalar(Bool), FALSE),
  workers = Optional(Scalar(Between(1, 64))),
  origins = Default(String, "*")
))

# Normally parse_env() reads Sys.getenv(); `env` makes the example reproducible
env <- c(
  DATABASE_URL = "postgres://localhost/app",
  APP_PORT     = "9000",
  APP_DEBUG    = "yes",
  APP_ORIGINS  = '["https://a.example", "https://b.example"]'
)

str(parse_env(Settings, prefix = "APP_", env = env))
List of 4
 $ db_url : chr "postgres://localhost/app"
 $ port   : int 9000
 $ debug  : logi TRUE
 $ origins: chr [1:2] "https://a.example" "https://b.example"

Whole numbers, numbers, logicals (true/false, yes/no, on/off, 1/0), dates and date-times are recognised, and values that look like JSON arrays or objects are parsed as JSON. Unset or empty variables fall back to Default() values and Optional() fields are left out.

A misconfigured app fails at startup, with every problem listed under its variable name:

parse_env(Settings, prefix = "APP_", env = c(
  APP_PORT    = "80.5",
  APP_DEBUG   = "maybe",
  APP_WORKERS = "100"
))
Error: 4 type errors found:
  * Type error in 'DATABASE_URL': Missing required environment variable (expected scalar<matches("^postgres://")>)
  * Type error in 'APP_PORT': cannot convert "80.5" to scalar<integer> = 8080
  * Type error in 'APP_DEBUG': cannot convert "maybe" to scalar<bool> = FALSE
  * Type error in 'APP_WORKERS': expected between[1, 64], but value [100] is outside [1, 64]

Rules that involve several settings can be added with Refine() on the list type, and a .env file can be loaded with readRenviron(".env") before calling parse_env().

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 in 'p$age': Missing required field (expected numeric)
# 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]

Every Problem, With Its Location

Errors report every problem at once instead of stopping at the first one, and each problem says where it is:

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

todos %:% ListOf(Todo) %<-% list(
  list(id = 1, title = "Buy milk"),
  list(id = "2", title = 3, extra_field = TRUE)
)
Error: 3 type errors found:
  * Type error in 'todos[[2]]$id': Expected numeric, got string
  * Type error in 'todos[[2]]$title': Expected string, got double
  * Type error in 'todos[[2]]$extra_field': Unexpected field (valid fields: id, title)

Positions and Rows

For vectors and data frame columns, errors say which elements failed:

Patients <- create_dataframe_type(list(age = Positive, name = NonNA(String)))
check_type(data.frame(age = c(31, -2, -5), name = c("Ann", NA, "Bo")), Patients)
Error: 2 type errors found:
  * Type error in 'age': expected positive, but values [-2, -5] are not > 0 (at rows 2, 3)
  * Type error in 'name': expected non_na<string> (no NA values), but value contains NA(s) (at row 2)

The positions are also in the index column of safe_parse() issues.

Suggestions for Typos

Misspelled field names, data frame columns, Enum() and Literal() values and DiscriminatedUnion() tags come with a suggestion:

Account <- create_list_type(list(name = String, status = Enum("active", "inactive")))
check_type(list(nmae = "Ann", status = "actve"), Account)
Error: 3 type errors found:
  * Type error in 'name': Missing required field (expected string)
  * Type error in 'status': Expected enum["active", "inactive"], got string. Did you mean "active"?
  * Type error in 'nmae': Unexpected field (valid fields: name, status). Did you mean 'name'?

Catching Type Errors

Type errors are conditions of class sicher_type_error. Their issues element lists each problem with its message, expected, got and code:

err <- tryCatch(
  check_type(list(id = "2", title = 3), Todo, context = "todo"),
  sicher_type_error = function(e) e
)

vapply(err$issues, function(issue) issue$message, character(1))
[1] "Expected numeric, got string" "Expected string, got double" 

For a data frame of issues with readable paths, use safe_parse() instead:

safe_parse(list(id = "2", title = 3), Todo, context = "todo")$issues
        path                      message expected    got         code index
1    todo$id Expected numeric, got string  numeric string invalid_type      
2 todo$title  Expected string, got double   string double invalid_type      

Errors for Forms and APIs

field_errors() groups the messages of a failed check by field, which is what a form needs to show each message next to its input:

Signup <- create_list_type(list(email = Matches("@"), age = Scalar(Between(18, 120))))
res <- safe_parse(list(email = "nope", age = 12), Signup)
field_errors(res)
$email
[1] "expected matches(\"@\"), but value [\"nope\"] does not match the pattern"

$age
[1] "expected between[18, 120], but value [12] is outside [18, 120]"

problem_json() turns them into an RFC 9457 “problem details” body for a 400 or 422 response. Each error has a JSON Pointer to the bad value:

str(problem_json(res, status = 422))
List of 5
 $ type  : chr "about:blank"
 $ title : chr "Invalid request"
 $ status: int 422
 $ detail: chr "2 validation errors"
 $ errors:List of 2
  ..$ :List of 4
  .. ..$ path   : chr "email"
  .. ..$ pointer: chr "/email"
  .. ..$ message: chr "expected matches(\"@\"), but value [\"nope\"] does not match the pattern"
  .. ..$ code   : chr "invalid_string"
  ..$ :List of 4
  .. ..$ path   : chr "age"
  .. ..$ pointer: chr "/age"
  .. ..$ message: chr "expected between[18, 120], but value [12] is outside [18, 120]"
  .. ..$ code   : chr "out_of_range"

In a plumber endpoint:

#* @post /signup
function(req, res) {
  parsed <- safe_parse(req$body, Signup)
  if (!parsed$success) {
    res$status <- 422
    return(problem_json(parsed, status = 422))
  }
  create_account(parsed$data)
}

Forgotten Parentheses

Using a type factory without calling it gives a hint:

size %:% Factor %<-% factor("M")
Error: Invalid type specification: expected a sicher_type, sicher_union, or sicher_readonly (e.g. Numeric, String | Bool, Optional(Integer)); got function. Did you forget to call a type factory, e.g. Factor() instead of Factor?

Warnings Instead of Errors

With options(sicher.mode = "warn"), violations become warnings and the assigned value is kept. This helps when adding sicher to existing code. Readonly() is still enforced.

options(sicher.mode = "warn")

score %:% Numeric %<-% 10
score <- "ten"
Warning: Type error in 'score': Expected numeric, got string
Received: ten
score
[1] "ten"
options(sicher.mode = "on")