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.
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 1Details 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.994A 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.2Both 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 1042Instances 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.2The 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] -1LThat 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/FALSEEvery 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:
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 1The [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 NAWhen 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 implicitlyName 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 | NULLConstruct 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 1A 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, friendA 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 nameInstances are plain lists, so they behave like every other R value: copy on modify, no action at a distance.
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 numberErrors 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] TRUEInheritance, 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 : NULLWhen 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 1The 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 implicitlyAn 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 nameGive ... 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] 3Checks 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:
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.97The 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.03The 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.2And a factory that forgets to return a function is caught by its own return type, before anyone can call the result:
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 1And 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] 0See 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
specattribute (name,validate,schema).[.typecaptures its argument withsubstitute()— a bare number is a length, an expression mentioning.is a predicate.Ops.typeimplements|. That is the whole DSL; there is no parser. -
Structs compile to constructors with real formals, built with
formals<-andbquote(). Instances are plain lists carrying their ownspec, so they copy on modify like every other R value andidentical(),saveRDS()andstr()behave;$<-dispatches to a method that validates and returns the updated value. -
Validators return the value itself, or a
typed_problemslist of records. The happy path allocates nothing beyond the value, andrdantic.strictis 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 reserveddeclare()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 inframe()beyond predicates over a column.