Skip to contents

Declared types, checked at the boundary. pydantic’s idea, in base R.

Status: proof of concept. No S4, no R6, no code generation — closures, attributes and plain lists. Optional two-way interop with S7.

Feedback wanted!

Concept by humans, implemented by AI.

library(rdantic)   # base R only; jsonlite is needed for the JSON helpers

TL;DR

# define a struct with typed fields, optional fields, defaults and descriptions
User <- struct(
  .name = "User",
  .description = "A description of the structure User",
  id   = int[1][. > 0]                            %doc% "The ID of the user.",
  name = chr[1]                                   %doc% "Full name of the user.",
  role = one_of("admin", "user") %default% "user" %doc% "Description of role.",
  age  = (int[1] | NULL)                          %doc% "Age in years, if known."
)
User
#> <struct> User
#>   id   : int[1][. > 0]
#>   name : chr[1]
#>   role : one_of("admin", "user") = "user"
#>   age  : int[1] | NULL

# create a new instance, validated and coerced
ada <- User(id = 42, name = "Ada")

str(ada, give.attr = FALSE) # the object is a plain list, but it carries its own spec
#> List of 4
#>  $ id  : int 42
#>  $ name: chr "Ada"
#>  $ role: chr "user"
#>  $ age : NULL

# wrong id type, no implicit conversion
wrong <- User(id = "42", name = "Ada")
#> Error:
#> ! 1 validation problem in User
#>   $id  expected int[1][. > 0], got chr[1] "42"  -- strings are never parsed implicitly

to_json(schema(User))                     # descriptions land in the schema, not just a comment
#> {
#>   "type": "object",
#>   "title": "User",
#>   "description": "A description of the structure User",
#>   "properties": {
#>     "id": {
#>       "type": "integer",
#>       "description": "The ID of the user."
#>     },
#>     "name": {
#>       "type": "string",
#>       "description": "Full name of the user."
#>     },
#>     "role": {
#>       "enum": [
#>         "admin",
#>         "user"
#>       ],
#>       "description": "Description of role."
#>     },
#>     "age": {
#>       "type": ["integer", "null"],
#>       "description": "Age in years, if known."
#>     }
#>   },
#>   "required": [
#>     "id",
#>     "name"
#>   ],
#>   "additionalProperties": false
#> }

greet <- fn(u = User, ~ chr[1], { paste("Hi,", u$name) })

greet(ada)                                # right: a real User
#> [1] "Hi, Ada"
greet(User(id = "1", name = "Ada"))       # wrong: every problem, with its path
#> Error:
#> ! 1 validation problem in User
#>   $id  expected int[1][. > 0], got chr[1] "1"  -- strings are never parsed implicitly
greet(User(id = 2, name = "Bob"))         # combined: build and call in one step
#> [1] "Hi, Bob"

greet_wrong <- fn(u = User, ~ chr[1], { paste0("Hi, ", u$name, "(", seq(u$id), ")") }) # wrong: return type is wrong
greet_wrong(ada)
#> Error:
#> ! 1 validation problem in greet_wrong()
#>   <return>  expected chr[1], got chr[42]  -- length is 42, not 1

Details about the types, structs, functions and JSON follow.

int[1][. > 0](5)                          # a type is a value: calling it validates + coerces
#> [1] 5
int[1][. > 0](-1)
#> Error:
#> ! 1 validation problem in int[1][. > 0]
#>   <value>  expected int[1][. > 0], got num[1] -1
ada$role                                  # "user" -- filled in by %default%
#> [1] "user"
ada$age <- "old"                          # instances stay valid: checked on assignment too
#> Error:
#> ! 1 validation problem in User
#>   $age  expected int[1] | NULL, got chr[1] "old"  -- strings are never parsed implicitly
budget <- map_of(int[1][. > 0])           # a map: any keys, one value type
budget(list(cpu = 4, memory = 16))
#> $cpu
#> [1] 4
#> 
#> $memory
#> [1] 16
from_json(User, '{"id": 1, "name": "Ada"}')
#> <User>
#>   id   : 1L
#>   name : "Ada"
#>   role : "user"
#>   age  : NULL
from_json(User, '{"id": -1, "name": "Ada"}')  # JSON in, validated against the same declaration
#> Error:
#> ! 1 validation problem in User
#>   $id  expected int[1][. > 0], got int[1] -1L
Types are values int[1], num[. > 0], chr[1] \\| NULL — built from [ and \\|, no new syntax
Length is part of the type int is any integer vector, int[1] is exactly one
Every problem, with a path not the first failure — all of them, each saying where
Lossless coercion only 1 → 1L yes; 1.5 → 1L no; "1" → 1L never
Records and functions struct() for validated objects, fn() for typed functions
JSON in, JSON out from_json(), to_json(), schema()

Motivation

A payload arrives from somewhere you don’t control. Plain R happily computes an answer.

payload <- list(id = "1042", qty = 3, unit_price = 19.99, coupon = 1.2)

total <- function(o) o$qty * o$unit_price * (1 - o$coupon)
total(payload)
#> [1] -11.994

A negative total. No warning, no error. id is a string, coupon is above 1, and the bug surfaces three calls later, in a report. Most R bugs look like this: not a crash, but a value that was the wrong shape all along.

Declare what the data is, once:

Order <- struct("Order",                  # name, then one `field = type` per column
  id         = int[1][. > 0],
  qty        = int[1][. > 0],
  unit_price = num[1][. >= 0],
  coupon     = num[1][0 <= . & . <= 1] %default% 0
)

Order(id = "1042", qty = 3, unit_price = 19.99, coupon = 1.2)
#> Error:
#> ! 2 validation problems in Order
#>   $id      expected int[1][. > 0], got chr[1] "1042"  -- strings are never parsed implicitly
#>   $coupon  expected num[1][0 <= . & . <= 1], got num[1] 1.2

Both mistakes at once, each naming the field and the rule it broke. Good data goes through, coerced where that is lossless:

order <- Order(id = 1042, qty = 3, unit_price = 19.99)
order
#> <Order>
#>   id         : 1042L
#>   qty        : 3L
#>   unit_price : 19.99
#>   coupon     : 0
str(order$id)                            # the double 1042 arrived as an integer
#>  int 1042

Instances stay valid for their whole life, and a typed function can neither be called nor return the wrong thing:

order$qty <- 0
#> Error:
#> ! 1 validation problem in Order
#>   $qty  expected int[1][. > 0], got num[1] 0

net <- fn(o = Order, ~ num[1][. >= 0], {  # arg = type, ..., ~ return type, then the body
  o$qty * o$unit_price * (1 - o$coupon)
})
net(order)
#> [1] 59.97
net(payload)
#> Error:
#> ! 2 validation problems in net()
#>   o$id      expected int[1][. > 0], got chr[1] "1042"  -- strings are never parsed implicitly
#>   o$coupon  expected num[1][0 <= . & . <= 1], got num[1] 1.2

The same declaration is also a contract with a model you don’t control. Structured-output APIs want a JSON Schema up front, and whatever they hand back still has to be checked before you trust it — normally two artifacts, written by hand, free to drift apart.

Extraction <- struct("Extraction",
  name = chr[1],
  age  = int[1][. > 0],
  tags = list_of(chr[1])                  # list_of(T): a list of any length, all elements T
)

to_json(schema(Extraction))               # schema(T) builds a JSON Schema; to_json() serializes it
#> {
#>   "type": "object",
#>   "title": "Extraction",
#>   "properties": {
#>     "name": {
#>       "type": "string"
#>     },
#>     "age": {
#>       "type": "integer",
#>       "description": "satisfies . > 0"
#>     },
#>     "tags": {
#>       "type": "array",
#>       "items": {
#>         "type": "string"
#>       }
#>     }
#>   },
#>   "required": [
#>     "name",
#>     "age",
#>     "tags"
#>   ],
#>   "additionalProperties": false
#> }

Send that schema as the response format; whatever comes back goes through from_json() — one declaration on both ends, so a reply that ignores the schema is still caught.

reply <- '{"name": "Ada", "age": -1, "tags": ["r", "stats"]}'
from_json(Extraction, reply)              # from_json(T, txt): parse JSON text, validate against T
#> Error:
#> ! 1 validation problem in Extraction
#>   $age  expected int[1][. > 0], got int[1] -1L

That is the whole idea. The rest of this document is that idea applied to vectors, records, nested data and JSON.

Types

The four primitives cover R’s atomic vectors; [ ] adds length and constraints, | adds alternatives, and a handful of constructors add structure.

int; num; chr; lgl
#> <type> int
#> <type> num
#> <type> chr
#> <type> lgl
int[1]
#> <type> int[1]
num[. > 0]
#> <type> num[. > 0]
chr[1] | NULL
#> <type> chr[1] | NULL
no_na(num)                                # no_na(T): T, but NA is rejected
#> <type> no_na(num)
one_of("low", "mid", "high")              # one_of(...): an enum of these exact values
#> <type> one_of("low", "mid", "high")
fct("low", "mid", "high")                 # fct(...): a factor restricted to these levels
#> <type> fct("low", "mid", "high")
list_of(int[1])                           # list_of(T): a list of any length, all elements T
#> <type> list_of(int[1])
map_of(int[1])                            # map_of(T): arbitrary keys, one value type
#> <type> map_of(int[1])
list_of(cpu = int[1], memory = int[1])    # list_of(a = T, b = T, ...): fixed keys, one type each
#> <type> list_of(cpu, memory)
frame(id = int, label = chr)              # frame(col = T, ...): a data.frame with typed columns
#> <type> frame(id, label)

Because types are values, you name them and reuse them:

id_t    <- int[1][. > 0]
email_t <- chr[1][grepl("@", ., fixed = TRUE)]

Use them exactly like any other type – call them to validate:

id_t(1)
#> [1] 1
id_t(c(0, 1))
#> Error:
#> ! 1 validation problem in int[1][. > 0]
#>   <value>  expected int[1][. > 0], got num[2]  -- length is 2, not 1

email_t("something@example.com")
#> [1] "something@example.com"
email_t("This is not an email")
#> Error:
#> ! 1 validation problem in chr[1][grepl("@", ., fixed = TRUE)]
#>   <value>  expected chr[1][grepl("@", ., fixed = TRUE)], got chr[1] "This is not an email"

A type is callable: calling it validates a value and hands it back, coerced when that loses nothing.

int(c(1, 2, 3))          # whole doubles  -> integer
#> [1] 1 2 3
num(2L)                  # integer        -> double
#> [1] 2
chr(factor("a"))         # factor         -> character
#> [1] "a"
int(1.5)                 # lossy
#> Error:
#> ! 1 validation problem in int
#>   <value>  expected int, got num[1] 1.5  -- not a whole number
int("3")                 # parsing strings is never implicit
#> Error:
#> ! 1 validation problem in int
#>   <value>  expected int, got chr[1] "3"  -- strings are never parsed implicitly
lgl(1)
#> Error:
#> ! 1 validation problem in lgl
#>   <value>  expected lgl, got num[1] 1  -- 0/1 is not TRUE/FALSE

Every error names what was expected and what arrived — type, length, and for scalars the value. That shape is the same everywhere in the system.

Want no coercion at all? Values must then already be the right R type:

options(rdantic.strict = TRUE)
int(1)
#> Error:
#> ! 1 validation problem in int
#>   <value>  expected int, got num[1] 1  -- rdantic.strict = TRUE, so no coercion was attempted
options(rdantic.strict = FALSE)

Length

R’s central data structure is the vector, so a type without a length is a type of vectors. T[n] fixes the length; T[1] is a scalar.

int[3](c(1, 2, 3))
#> [1] 1 2 3
int[3](c(1, 2))
#> Error:
#> ! 1 validation problem in int[3]
#>   <value>  expected int[3], got num[2]  -- length is 2, not 3
int[1](c(1, 2))
#> Error:
#> ! 1 validation problem in int[1]
#>   <value>  expected int[1], got num[2]  -- length is 2, not 1

The [1] matters more than it looks: most bugs from R’s recycling rules come from a vector arriving where one value was meant.

For a range rather than an exact length, use length(.): inside [ ], . is bound to the whole vector, not one element at a time, so length(.) is the vector’s length.

int[length(.) <= 3](c(1, 2))
#> [1] 1 2
int[length(.) <= 3](c(1, 2, 3, 4))
#> Error:
#> ! 1 validation problem in int[length(.) <= 3]
#>   <value>  expected int[length(.) <= 3], got num[4]

int[length(.) >= 10](1:10)
#>  [1]  1  2  3  4  5  6  7  8  9 10
int[length(.) >= 10](1:5)
#> Error:
#> ! 1 validation problem in int[length(.) >= 10]
#>   <value>  expected int[length(.) >= 10], got int[5]

Constraints

Anything inside [ ] that mentions . is a predicate over the value. It must return TRUE for every element. Constraints and lengths chain in any order.

num[. > 0](c(1, 2))
#> [1] 1 2
num[. > 0](c(1, -2))
#> Error:
#> ! 1 validation problem in num[. > 0]
#>   <value>  expected num[. > 0], got num[2]  -- fails at element 2

int[1][. > 5](9)
#> [1] 9
int[1][. > 5](3)
#> Error:
#> ! 1 validation problem in int[1][. > 5]
#>   <value>  expected int[1][. > 5], got num[1] 3

chr[1][nchar(.) <= 3]("abcd")
#> Error:
#> ! 1 validation problem in chr[1][nchar(.) <= 3]
#>   <value>  expected chr[1][nchar(.) <= 3], got chr[1] "abcd"

The constraint’s source text becomes part of the type’s name, so the error tells the reader the rule that was broken, not just that one was.

Unions, optionals, defaults

A | B accepts either. T | NULL is the idiom for optional. %default% attaches a default value, used by structs and typed functions when a value is not supplied.

(int[1] | chr[1])(7)
#> [1] 7
(int[1] | chr[1])("seven")
#> [1] "seven"
(int[1] | chr[1])(TRUE)
#> Error:
#> ! 1 validation problem in int[1] | chr[1]
#>   <value>  expected int[1] | chr[1], got lgl[1] TRUE

(int[1] | NULL)(NULL)
#> NULL

one_of("admin", "user") %default% "user"
#> <type> one_of("admin", "user")  (default: "user")
int[1] | NULL %default% NULL              # a default on either side applies to the union
#> <type> int[1] | NULL  (default: NULL)

Dates, factors, missing values

R’s other quiet failure modes: a Date is a number wearing a class, a factor is an integer wearing labels, and NA sails through every operator. Each gets a type.

date("2024-05-17")                        # ISO-8601 is unambiguous, so it is parsed
#> [1] "2024-05-17"
date("17/05/2024")
#> Error:
#> ! 1 validation problem in date
#>   <value>  expected date, got chr[1] "17/05/2024"  -- expected YYYY-MM-DD
num(Sys.Date())                           # ... but a Date is never silently a number
#> Error:
#> ! 1 validation problem in num
#>   <value>  expected num, got date[1] "2026-09-25"  -- a <Date> is never silently unclassed
fct("low", "high")("high")
#> [1] high
#> Levels: low high
no_na(num)(c(1, NA))
#> Error:
#> ! 1 validation problem in no_na(num)
#>   <value>  expected no_na(num), got num[2]  -- contains NA

When nothing built in fits, type_from() takes a plain predicate and an optional coercion:

# type_from(name, predicate, coerce = NULL)
hex <- type_from("hex", function(x) is.character(x) && all(grepl("^#[0-9a-f]{6}$", x)))
hex("#00ff99")
#> [1] "#00ff99"
hex("green")
#> Error:
#> ! 1 validation problem in hex
#>   <value>  expected hex, got chr[1] "green"

Named lists

Give map_of() one type and you get a map: any keys, one value type. Keys are kept, and a failure is reported by key rather than by position — this is what a JSON object with arbitrary keys parses into. list_of() given one type is the array counterpart: it only accepts an unnamed list, so the two never overlap.

budget <- map_of(int[1][. > 0])

budget(list(cpu = 4, memory = 16))
#> $cpu
#> [1] 4
#> 
#> $memory
#> [1] 16
budget(list(cpu = 4, memory = 0, disk = "big"))
#> Error:
#> ! 2 validation problems in map_of(int[1][. > 0])
#>   $memory  expected int[1][. > 0], got num[1] 0
#>   $disk    expected int[1][. > 0], got chr[1] "big"  -- strings are never parsed implicitly

Name the arguments of list_of() instead and you get a third shape: a fixed key set with one rule per key — still a plain named list on the way out, with no class and no struct name.

limits <- list_of(cpu = int[1][0 < . & . < 12], memory = int[1][0 < . & . < 128])
limits
#> <type> list_of(cpu, memory)

str(limits(list(cpu = 4, memory = 16)))
#> List of 2
#>  $ cpu   : int 4
#>  $ memory: int 16
limits(list(cpu = 16, memory = 512))
#> Error:
#> ! 2 validation problems in list_of(cpu, memory)
#>   $cpu     expected int[1][0 < . & . < 12], got num[1] 16
#>   $memory  expected int[1][0 < . & . < 128], got num[1] 512
limits(list(cpu = 4))
#> Error:
#> ! 1 validation problem in list_of(cpu, memory)
#>   $memory  expected int[1][0 < . & . < 128], got <missing>
to_json(limits(list(cpu = 4L, memory = 16L)))
#> {
#>   "cpu": 4,
#>   "memory": 16
#> }

Missing keys are reported, %default% fills them in, and .extra = "forbid" refuses unknown ones — the same record rules struct() uses. Reach for a struct when the record deserves a name, a class and inheritance; reach for list_of(a = T, ...) when it is just a shape.

Records: struct()

A struct is a set of named, typed fields. struct() returns a constructor, and that constructor is itself a type (so structs nest and can be used anywhere a type is).

User <- struct("User",
  id     = id_t,
  name   = chr[1],
  email  = email_t,
  age    = int[1] | NULL,
  role   = one_of("admin", "user") %default% "user",
  tags   = chr %default% character(),
  friend = opt("User")                    # optional, refers to this struct by name
)
User
#> <struct> User
#>   id     : int[1][. > 0]
#>   name   : chr[1]
#>   email  : chr[1][grepl("@", ., fixed = TRUE)]
#>   age    : int[1] | NULL
#>   role   : one_of("admin", "user") = "user"
#>   tags   : chr = character(0)
#>   friend : User | NULL

Construct one like any R function — the constructor has real formals, so autocomplete works:

ada <- User(id = 1, name = "Ada", email = "ada@lovelace.org")
ada
#> <User>
#>   id     : 1L
#>   name   : "Ada"
#>   email  : "ada@lovelace.org"
#>   age    : NULL
#>   role   : "user"
#>   tags   : character(0)
#>   friend : NULL
str(ada$id)                              # coerced to integer on the way in
#>  int 1

A field is required unless it has a default or accepts NULL. Every problem is reported:

User(name = 42, email = "nope", role = "god")
#> Error:
#> ! 4 validation problems in User
#>   $id     expected int[1][. > 0], got <missing>
#>   $name   expected chr[1], got num[1] 42
#>   $email  expected chr[1][grepl("@", ., fixed = TRUE)], got chr[1] "nope"
#>   $role   expected one_of("admin", "user"), got chr[1] "god"

Fields are validated on assignment too, and the object is sealed — a typo cannot quietly invent a field, or quietly read a different one. $<-, [[<- and [<- all run the same check, so there is no assignment form that skips it:

ada$age <- 36L
ada$age
#> [1] 36
ada$age <- "thirty-six"
#> Error:
#> ! 1 validation problem in User
#>   $age  expected int[1] | NULL, got chr[1] "thirty-six"  -- strings are never parsed implicitly
ada["age"] <- "thirty-six"               # [<- checks the field just like $<- does
#> Error:
#> ! 1 validation problem in User
#>   $age  expected int[1] | NULL, got chr[1] "thirty-six"  -- strings are never parsed implicitly
ada$nickname <- "Countess"
#> Error:
#> ! 1 validation problem in User
#>   $nickname  expected <no such field>, got chr[1] "Countess"
ada$nam
#> Error:
#> ! <User> has no field `nam` -- did you mean `name`?
#>   fields: id, name, email, age, role, tags, friend

A field name that collides with rdantic’s own internals (like .args) is rejected when the struct is declared:

struct("Broken", .args = int[1])
#> Error:
#> ! field name `.args` is reserved for rdantic's internals; choose a different name

Instances are plain lists, so they behave like every other R value: copy on modify, no action at a distance.

copy <- ada
copy$name <- "Grace"
c(ada$name, copy$name)
#> [1] "Ada"   "Grace"
identical(ada, User(id = 1, name = "Ada", email = "ada@lovelace.org", age = 36L))
#> [1] TRUE

Nesting, and errors that report everything

Since a struct is a type, structs compose. Where a struct is expected, a plain list is accepted and parsed — this is what makes JSON input work later.

Team <- struct("Team",
  lead    = User,
  members = list_of(User),
  roster  = frame(id = int, name = chr)
)

bob  <- User(id = 2, name = "Bob", email = "bob@example.org", friend = ada)
team <- Team(lead = ada, members = list(bob),
             roster = data.frame(id = c(1, 2), name = c("Ada", "Bob")))
team
#> <Team>
#>   lead    : <User>
#>   members : <list of 1>
#>   roster  : <data.frame 2 x 2>
team$members[[1]]$friend$name
#> [1] "Ada"

Three mistakes at three depths, one error:

Team(
  lead    = list(id = 1, name = "Ada", email = "ada.lovelace"),
  members = list(list(id = 2, name = "Bob", email = "b@b", age = 34.5)),
  roster  = data.frame(id = c(1, 2.5), name = c("a", "b"))
)
#> Error:
#> ! 3 validation problems in Team
#>   $lead$email        expected chr[1][grepl("@", ., fixed = TRUE)], got chr[1] "ada.lovelace"
#>   $members[[1]]$age  expected int[1] | NULL, got num[1] 34.5  -- not a whole number
#>   $roster$id         expected int, got num[2]  -- not a whole number

Errors are machine-readable: a condition of class typed_error whose $problems is a list of path / expected / got / hint records, so tooling can consume it, not just people.

tryCatch(User(id = "x", name = 1), typed_error = function(e)
  data.frame(
    path     = vapply(e$problems, `[[`, "", "path"),
    expected = vapply(e$problems, `[[`, "", "expected"),
    got      = vapply(e$problems, `[[`, "", "got")
  ))
#>     path                            expected        got
#> 1    $id                       int[1][. > 0] chr[1] "x"
#> 2  $name                              chr[1]   num[1] 1
#> 3 $email chr[1][grepl("@", ., fixed = TRUE)]  <missing>

When wrong input is expected rather than exceptional — a web form, an upload — use try_parse(), which returns the same records instead of throwing:

r <- try_parse(User, list(id = 1.5, name = "Ada", email = "nope"))  # (T, x): validate, don't throw
r$ok
#> [1] FALSE
str(r$problems[[2]])
#> List of 4
#>  $ path    : chr "$email"
#>  $ expected: chr "chr[1][grepl(\"@\", ., fixed = TRUE)]"
#>  $ got     : chr "chr[1] \"nope\""
#>  $ hint    : NULL
is_valid(User, list(id = 1, name = "Ada", email = "a@b"))            # (T, x): TRUE/FALSE, no detail
#> [1] TRUE

Inheritance, partials, unknown fields

Admin <- extend(User, "Admin", permissions = list_of(chr[1]))  # (Base, "Name", extra field = type, ...)
root  <- Admin(id = 1, name = "root", email = "root@example.org",
               permissions = list("all"))
inherits(root, "User")
#> [1] TRUE
ada$friend <- root                       # an Admin is a User

UserPatch <- partial(User)               # every field optional, defaults dropped
UserPatch(name = "Grace")
#> <PartialUser>
#>   id     : NULL
#>   name   : "Grace"
#>   email  : NULL
#>   age    : NULL
#>   role   : NULL
#>   tags   : NULL
#>   friend : NULL

When parsing from a list, unknown keys are ignored by default (as in pydantic, and so that JSON written from a subclass still parses as the parent). A struct can forbid them:

Strict <- struct("Strict", x = int[1], .extra = "forbid")
from_list(Strict, list(x = 1, y = 2))     # from_list(T, x): parse/validate a plain list as T
#> Error:
#> ! 1 validation problem in Strict
#>   $y  expected <no such field>, got num[1] 2  -- did you mean `x`?

frame() has the same .extra, enforced the same way whether the rows arrive as a data.frame already or as JSON that gets reshaped into one:

Rows <- frame(id = int, .extra = "forbid")
Rows(data.frame(id = 1L, extra = 2))
#> Error:
#> ! 1 validation problem in frame(id)
#>   $extra  expected <no such column>, got num[1] 2
from_json(Rows, '[{"id": 1, "extra": 2}]')
#> Error:
#> ! 1 validation problem in frame(id)
#>   $extra  expected <no such column>, got <present in JSON>

Typed functions: fn()

Types sit where R would put defaults; an unnamed formula ~ T declares the return type; the last unnamed expression is the body. Defaults use %default%; arguments whose type accepts NULL may be omitted.

greet <- fn(name = chr[1], greeting = chr[1] %default% "Hello", ~ chr[1], {
  paste0(greeting, ", ", name, "!")
})
greet
#> <fn> (name: chr[1], greeting: chr[1] = "Hello") -> chr[1]
greet("Ada")
#> [1] "Hello, Ada!"
greet("Ada", "Bonjour")
#> [1] "Bonjour, Ada!"
greet(c("Ada", "Bob"))
#> Error:
#> ! 1 validation problem in greet()
#>   name  expected chr[1], got chr[2]  -- length is 2, not 1

The return value is checked as well — a typed function cannot leak a wrong result, and an explicit return() is checked exactly like a value that falls through:

half <- fn(x = int[1], ~ int[1], { x / 2 })
half(4)                                  # 2 is a whole double: coerces to 2L
#> [1] 2
half(3)                                  # 1.5 is not an int
#> Error:
#> ! 1 validation problem in half()
#>   <return>  expected int[1], got num[1] 1.5  -- not a whole number

early <- fn(x = int[1], ~ int[1], { if (x < 0) return("negative"); x })
early(-1)                                # return()'s value is checked too
#> Error:
#> ! 1 validation problem in early()
#>   <return>  expected int[1], got chr[1] "negative"  -- strings are never parsed implicitly

An argument name that collides with rdantic’s own internals (like .probs_) is rejected when the function is declared, rather than silently shadowed:

fn(.probs_ = int[1], ~ int[1], { .probs_ })
#> Error:
#> ! argument name `.probs_` is reserved for rdantic's internals; choose a different name

Give ... a type to check everything passed through it. Every bad argument is reported in one error, as with structs:

log_line <- fn(level = one_of("info", "warn"), ... = chr[1], ~ chr[1], {
  paste0("[", level, "] ", paste(..., collapse = " "))
})
log_line("info", "disk", "full")
#> [1] "[info] disk full"
log_line("debug", "disk", 3)
#> Error:
#> ! 2 validation problems in log_line()
#>   level  expected one_of("info", "warn"), got chr[1] "debug"
#>   ..2    expected chr[1], got num[1] 3

Checks are compiled once, when the function is defined, and cost tens of microseconds per call — nothing at an API boundary, noticeable in a tight loop. Turn them off globally when you no longer want to pay for them:

options(rdantic.check = FALSE)
half(3)
#> [1] 1.5
options(rdantic.check = TRUE)

Composing typed functions

Since a type is just a value plus a predicate, a function type needs no new machinery:

pricing_rule <- anything[inherits(., "typed_fn")]
Money        <- num[1][. >= 0]

rule_for() returns a typed function; invoice() takes one. Three typed functions, stacked:

rule_for <- fn(kind = one_of("standard", "bulk", "clearance"), ~ pricing_rule, {
  switch(kind,
    standard  = fn(o = Order, ~ Money, { o$qty * o$unit_price }),
    bulk      = fn(o = Order, ~ Money, { o$qty * o$unit_price * 0.9 }),
    clearance = fn(o = Order, ~ Money, { o$qty * o$unit_price - 500 })
  )
})

invoice <- fn(o = Order, rule = pricing_rule, ~ Money, {
  round(rule(o), 2)
})

invoice(order, rule_for("bulk"))
#> [1] 53.97

The clearance rule is well-typed on the way in and wrong on the way out. Nothing catches it until the arithmetic has already run:

invoice(order, rule_for("clearance"))
#> Error:
#> ! 1 validation problem in rule()
#>   <return>  expected num[1][. >= 0], got num[1] -440.03

The report names the frame that produced the bad value (rule(), the innermost of the three) and the stage (<return>), not the outer call the user made. Compare the other three places the same pipeline can break — each one is caught as early as it possibly can be:

rule_for("half off")                     # (1) argument, at the door
#> Error:
#> ! 1 validation problem in rule_for()
#>   kind  expected one_of("standard", "bulk", "clearance"), got chr[1] "half off"
invoice(order, function(o) 0)            # (2) argument: a function, but not a typed one
#> Error:
#> ! 1 validation problem in invoice()
#>   rule  expected anything[inherits(., "typed_fn")], got function[1]
invoice(payload, rule_for("bulk"))       # (2) argument: raw list, parsed then rejected
#> Error:
#> ! 2 validation problems in invoice()
#>   o$id      expected int[1][. > 0], got chr[1] "1042"  -- strings are never parsed implicitly
#>   o$coupon  expected num[1][0 <= . & . <= 1], got num[1] 1.2

And a factory that forgets to return a function is caught by its own return type, before anyone can call the result:

broken_for <- fn(kind = chr[1], ~ pricing_rule, { toupper(kind) })
broken_for("bulk")
#> Error:
#> ! 1 validation problem in broken_for()
#>   <return>  expected anything[inherits(., "typed_fn")], got chr[1] "BULK"

JSON and schema

to_json() is type-aware: a field declared [1] becomes a JSON scalar, any other vector becomes an array, even when it has one element.

grace <- User(id = 3, name = "Grace", email = "grace@navy.mil", tags = "cobol")
to_json(grace)
#> {
#>   "id": 3,
#>   "name": "Grace",
#>   "email": "grace@navy.mil",
#>   "age": null,
#>   "role": "user",
#>   "tags": ["cobol"],
#>   "friend": null
#> }

from_json() parses and validates in one step. JSON arrays come back as R vectors and arrays of objects as data frames, because the types accept those shapes — there is no separate JSON mode.

team2 <- from_json(Team, to_json(team))
str(team2$roster)
#> 'data.frame':    2 obs. of  2 variables:
#>  $ id  : int  1 2
#>  $ name: chr  "Ada" "Bob"
from_json(User, '{"id": 1.5, "name": ["a", "b"], "email": "x@y"}')
#> Error:
#> ! 2 validation problems in User
#>   $id    expected int[1][. > 0], got num[1] 1.5  -- not a whole number
#>   $name  expected chr[1], got list[2]  -- length is 2, not 1

And a JSON Schema for interoperating with anything else that speaks types:

to_json(schema(User))
#> {
#>   "type": "object",
#>   "title": "User",
#>   "properties": {
#>     "id": {
#>       "type": "integer",
#>       "description": "satisfies . > 0"
#>     },
#>     "name": {
#>       "type": "string"
#>     },
#>     "email": {
#>       "type": "string",
#>       "description": "satisfies grepl(\"@\", ., fixed = TRUE)"
#>     },
#>     "age": {
#>       "type": ["integer", "null"]
#>     },
#>     "role": {
#>       "enum": [
#>         "admin",
#>         "user"
#>       ]
#>     },
#>     "tags": {
#>       "type": "array",
#>       "items": {
#>         "type": "string"
#>       }
#>     },
#>     "friend": {
#>       "anyOf": [
#>         {
#>           "$ref": "#/$defs/User"
#>         },
#>         {
#>           "type": "null"
#>         }
#>       ]
#>     }
#>   },
#>   "required": [
#>     "id",
#>     "name",
#>     "email"
#>   ],
#>   "additionalProperties": false
#> }

What schema() declares always matches what to_json() actually writes: a frame() column’s schema describes one row’s scalar cell, not the whole column’s vector shape, and map_of()’s schema is an object, matching the JSON object it serializes to.

Cell <- struct("Cell", rows = frame(id = int), tags = map_of(chr[1]))
c(schema(Cell)$properties$rows$items$properties$id$type,
  schema(Cell)$properties$tags$type)
#> [1] "integer" "object"
to_json(Cell(rows = data.frame(id = 1L), tags = list(a = "x")), pretty = FALSE)
#> {"rows":[{"id":1}],"tags":{"a":"x"}}

Documenting a schema

A description belongs in the schema itself, not beside it in a comment that can drift out of sync — this is what a structured-output model actually reads. For concise documentation, desc() (or its infix spelling %doc%) documents a single field’s type, and struct(.description = ) documents the record as a whole.

Contact <- struct("Contact",
  .description = "A single contact record.",
  name  = chr[1] %doc% "Full name of the person.",  # T %doc% "text": attach a description
  email = opt(chr[1]) %doc% "Email address, or null if unknown."
)

to_json(schema(Contact))
#> {
#>   "type": "object",
#>   "title": "Contact",
#>   "description": "A single contact record.",
#>   "properties": {
#>     "name": {
#>       "type": "string",
#>       "description": "Full name of the person."
#>     },
#>     "email": {
#>       "type": ["string", "null"],
#>       "description": "Email address, or null if unknown."
#>     }
#>   },
#>   "required": [
#>     "name"
#>   ],
#>   "additionalProperties": false
#> }

desc()/%doc% wraps a type, so — like %default% — it must be the outermost part of the chain, applied after [n], [expr], opt()/| and %default%:

age <- int[1][. > 0] %doc% "Age in years, must be positive."
schema(age)
#> $type
#> [1] "integer"
#> 
#> $description
#> [1] "Age in years, must be positive."

When documentation is long, declare the shape first and collect the prose in describe(). Its . argument describes the struct; every other argument is named for a field. Assign the result back to the struct name, as with any base R pipe that transforms an object.

UserProfile <- struct("UserProfile",
  id   = int[1][. > 0],
  name = chr[1],
  role = one_of("admin", "user") %default% "user"
) |>
  describe(
    . = r"(
      A user profile returned by the accounts API.

      Profiles may be consumed by several clients, so fields retain their
      documented meaning across API versions.
    )",
    id = r"(
      Stable identifier for the user. It is positive and never reused after
      an account has been deleted.
    )",
    name = "The user's full display name.",
    role = "Authorization role; defaults to ordinary user permissions."
  )

schema(UserProfile)$description
#> [1] "A user profile returned by the accounts API.\n\nProfiles may be consumed by several clients, so fields retain their\ndocumented meaning across API versions."
schema(UserProfile)$properties$id$description
#> [1] "Stable identifier for the user. It is positive and never reused after\nan account has been deleted."

Blank edge lines and common indentation are removed from every description, so long raw strings can follow the indentation of the surrounding R code without adding that whitespace to the generated JSON Schema. Embedded quotes do not need escaping.

S7 compatibility

S7 says what a class is; rdantic says what a value must satisfy, and where it failed. They compose in both directions. S7 is a suggested dependency — none of this loads unless you have it.

In S7, new_class() defines a class with named properties, calling the class constructs an object, and @ reads or writes a property. The examples below are enough to use the integration; vignette("s7") includes a short primer and the precise conversion rules.

An S7 class can be written wherever a type is expected, and a plain list parses into a real S7 object, so JSON works with no separate JSON mode:

Pin  <- S7::new_class("Pin", properties = list(lat = S7::class_double, lon = S7::class_double))
Trip <- struct("Trip", label = chr[1], start = Pin)

from_json(Trip, '{"label": "home", "start": {"lat": 52.52, "lon": 13.40}}')$start
#> <Pin>
#>  @ lat: num 52.5
#>  @ lon: num 13.4
from_list(Trip, list(label = "home", start = list(lat = "north")))
#> Error:
#> ! 2 validation problems in Trip
#>   $start$lat  expected num, got chr[1] "north"  -- strings are never parsed implicitly
#>   $start$lon  expected num, got <missing>

s7_struct() goes the other way: struct() with an S7 class as the result. The fields become S7 properties that validate — and coerce — through their rdantic types, on construction and on @<-. as_s7_class() does the same for a struct you already have.

Account <- s7_struct("Account", id = int[1][. > 0], credit = num[1][. >= 0] %default% 0)

a <- Account(id = 1)
a@credit <- 10L   # coerced to double, exactly as rdantic would
a@id <- 0
#> Error:
#> ! 1 validation problem in <Account>
#>   @id  expected int[1][. > 0], got num[1] 0

See vignette("s7") for the full mapping, schemas and inheritance.

Cheat sheet

Want Write
integer / double / string / logical vector int, num, chr, lgl
exactly n elements T[n]
constraint T[. > 0], chr[1][nchar(.) < 20]
optional T \\| NULL, opt(T), opt("StructName")
either A \\| B
enum one_of("a", "b")
date / time / factor date, datetime, fct("a", "b")
no missing values no_na(T)
custom type type_from("name", predicate, coerce)
list of list_of(T)
map (arbitrary keys) map_of(T)
named list with fixed keys list_of(a = T, b = T)
data.frame with typed columns frame(col = T, ...)
a function value anything[is.function(.)], anything[inherits(., "typed_fn")]
default T %default% value
record struct("Name", f = T, ...)
field / struct description T %doc% "text", struct(..., .description = "text")
forward / self reference ref("Name"), opt("Name")
subclass, partial extend(M, "Sub", ...), partial(M)
reject unknown keys struct(..., .extra = "forbid"), frame(..., .extra = "forbid")
typed function fn(a = T, ..., ~ Ret, { body })
typed ... fn(a = T, ... = T, ~ Ret, { body })
validate anything T(x), parse_as(T, x), from_list(M, x)
validate without throwing try_parse(T, x), is_valid(T, x)
fields of a struct or value fields(M), fields(x)
no coercion / no checks options(rdantic.strict = TRUE), options(rdantic.check = FALSE)
JSON to_json(x), from_json(T, txt), schema(T)
S7 interop an S7 class is a type; s7_struct("Name", f = T, ...), as_s7_class(M)
catch errors tryCatch(..., typed_error = function(e) e$problems)

How it works

  • Types are closures carrying a spec attribute (name, validate, schema). [.type captures its argument with substitute() — a bare number is a length, an expression mentioning . is a predicate. Ops.type implements |. That is the whole DSL; there is no parser.
  • Structs compile to constructors with real formals, built with formals<- and bquote(). Instances are plain lists carrying their own spec, so they copy on modify like every other R value and identical(), saveRDS() and str() behave; $<- dispatches to a method that validates and returns the updated value.
  • Validators return the value itself, or a typed_problems list of records. The happy path allocates nothing beyond the value, and rdantic.strict is read once per top-level validation rather than once per element.
  • fn() rewrites the function: types come off the formals, one check block is prepended, the return is wrapped. R 4.4 reserved declare() for exactly this kind of annotation; when it gains semantics, fn() can emit it and the checks move into the interpreter without user code changing.
  • Coercion is lossless only: whole doubles → int (never out of range), int → double, factor → character, ISO-8601 text → date. While parsing JSON, also: unnamed list of scalars → vector, list of records → data frame. Nothing else, ever.

Not in this proof of concept

  • Discriminated unions for polymorphic JSON ({"type": "admin", ...} choosing the struct).
  • Generic structs (Page(of = User)).
  • Cross-field validators (.check = function(self) ...) and row-wise constraints in frame() beyond predicates over a column.

Possible extensions

  • An actual ORM system for R
  • Extension/examples to make it compatible with ellmer for structured output