library(rdantic)
#>
#> Attaching package: 'rdantic'
#> The following object is masked from 'package:graphics':
#>
#> frame
#> The following object is masked from 'package:base':
#>
#> date
library(S7)S7 is the R
Consortium’s new object system: classes with named, typed properties,
single and multiple dispatch, and @ for property access.
rdantic and S7 answer different halves of the same question – S7 says
what a class is, rdantic says what a value must
satisfy and reports, with a path, every place the incoming data
failed to. They compose in both directions, and neither takes the other
over: S7 is a suggested dependency, so nothing in this vignette exists
unless you have S7 installed.
S7 in one minute
If S7 is new to you, four ideas are enough for this vignette:
-
new_class()creates a class. Calling that class creates an object. - A class declares named properties, each with an S7
class such as
class_double. Properties are supplied to the constructor by name. -
x@latreads a property andx@lat <- valuesets it. S7 checks the property’s class when the object is constructed or changed. - S7 generics dispatch on the class of one or more arguments, much like S3 methods but with an explicit class definition.
For example, Pin below is a class,
Pin(lat = 1, lon = 2) is an object, and lat
and lon are properties. This is only a working
introduction; the S7
documentation covers classes, properties and dispatch in depth.
Choose a direction
| Starting point | Goal | Use |
|---|---|---|
| An existing S7 class | Parse, validate or describe incoming data | Use the class directly, or call s7_type()
|
| A new model described with rdantic types | Produce S7 objects | s7_struct() |
| An existing rdantic struct | Hand it to S7-based code | as_s7_class() |
The rest of the vignette follows those three paths in that order.
Using an S7 class as a type
An S7 class can be written anywhere an rdantic type is expected.
as_type() – which struct(), fn(),
list_of() and friends all call – recognises it and hands it
to s7_type():
Pin <- new_class("Pin", properties = list(
lat = class_double,
lon = class_double
))
as_type(Pin)
#> <type> PinThat makes it a field type like any other. A plain list is parsed into a real S7 object, which is what makes JSON work with no separate JSON mode:
Trip <- struct("Trip",
label = chr[1],
start = Pin,
stop = Pin
)
t <- from_json(Trip, '{
"label": "commute",
"start": {"lat": 52.52, "lon": 13.40},
"stop": {"lat": 48.14, "lon": 11.58}
}')
t
#> <Trip>
#> label : "commute"
#> start : <Pin>
#> stop : <Pin>
class(t$start)
#> [1] "Pin" "S7_object"
t$start@lat
#> [1] 52.52Problems inside a property carry the path they occurred at, and every problem is reported at once:
from_list(Trip, list(
label = "commute",
start = list(lat = "north", lon = 13.40),
stop = list(lat = 48.14)
))
#> Error:
#> ! 2 validation problems in Trip
#> $start$lat expected num, got chr[1] "north" -- strings are never parsed implicitly
#> $stop$lon expected num, got <missing>An S7 object that is already of the right class is passed through untouched; one of a different class is rejected rather than silently re-parsed:
Trip(label = "ok", start = Pin(lat = 1, lon = 2), stop = 42)
#> Error:
#> ! 1 validation problem in Trip
#> $stop expected <Pin>, got num[1] 42Schemas, to_list() and to_json() all
understand S7 objects too:
schema(Pin)
#> $type
#> [1] "object"
#>
#> $title
#> [1] "Pin"
#>
#> $properties
#> $properties$lat
#> $properties$lat$type
#> [1] "array"
#>
#> $properties$lat$items
#> $properties$lat$items$type
#> [1] "number"
#>
#>
#>
#> $properties$lon
#> $properties$lon$type
#> [1] "array"
#>
#> $properties$lon$items
#> $properties$lon$items$type
#> [1] "number"
#>
#>
#>
#>
#> $required
#> $required[[1]]
#> [1] "lat"
#>
#> $required[[2]]
#> [1] "lon"
#>
#>
#> $additionalProperties
#> [1] FALSE
to_json(t$start)
#> {
#> "lat": [52.52],
#> "lon": [13.4]
#> }What carries over, and what does not
An S7 property declares a class; an rdantic type declares a class and a shape. The translation keeps what transfers and refuses to invent the rest:
| S7 property class | rdantic type |
|---|---|
class_integer, class_double,
class_character, class_logical
|
int, num, chr,
lgl
|
class_list |
list_of(anything) |
class_Date, class_POSIXct
|
date, datetime
|
class_data.frame |
an unconstrained data frame |
a new_union()
|
A \| B |
| another S7 class | that class, recursively |
anything else, including class_any
|
anything |
Note that class_double becomes num, not
num[1]: S7 places no length constraint on a property, so
neither does rdantic, and to_json() therefore writes a JSON
array. A property is required unless it declares a
default, which is the same rule %default%
follows for struct fields.
Vector <- new_class("Vector", properties = list(x = class_double))
from_list(Vector, list(x = c(1, 2, 3)))@x
#> [1] 1 2 3Native S7’s new_class() does not accept rdantic
expressions such as num[1] as property classes. To require
exactly one double, define the S7 class through rdantic:
Scalar <- s7_struct("Scalar", x = num[1])
Scalar(x = 1)
#> <Scalar>
#> @ x: num 1
Scalar(x = c(1, 2))
#> Error:
#> ! 1 validation problem in <Scalar>
#> @x expected num[1], got num[2] -- length is 2, not 1class_data.frame similarly says only that the value is a
data frame; it says nothing about column names or types. rdantic
preserves that meaning. Use an rdantic
frame(id = int, name = chr) field when you want a
particular table shape.
S7 details and boundaries
The conversion keeps S7’s own behavior around the translated fields:
- A property with a getter but no setter is computed. It is omitted from parsing, schemas and serialization because callers cannot supply it. A property with both a getter and setter remains a field.
- Constant and expression-valued S7 defaults make a property optional. Expressions are left for S7 to evaluate for each new object rather than being evaluated by rdantic.
- The class’s S7 validator still runs when rdantic constructs an object. An abstract S7 class can describe and validate existing objects, but a list cannot be parsed into a new instance of it.
- Unknown input keys are ignored when parsing into an existing S7
class. Classes made by
s7_struct()can instead use.extra = "forbid".
An existing object of the requested class is returned unchanged. Use
from_list() or from_json() on incoming data
when parsing and path-aware validation at a boundary is the goal.
Declaring an S7 class with rdantic types
s7_struct() is struct() with an S7 class as
the result. The fields are written exactly as they would be for a
struct, and every property validates through its rdantic type – on
construction and on @<-:
Account <- s7_struct("Account",
id = int[1][. > 0],
email = chr[1][grepl("@", ., fixed = TRUE)],
credit = num[1][. >= 0] %default% 0
)
a <- Account(id = 1, email = "ada@example.org")
a@credit
#> [1] 0Coercion happens exactly where rdantic would coerce it, so the property holds the value rdantic promised rather than the one you happened to type:
a@credit <- 10L
typeof(a@credit)
#> [1] "double"and the failures are the same typed_errors, with the
property named as @field:
a@id <- 0
#> Error:
#> ! 1 validation problem in <Account>
#> @id expected int[1][. > 0], got num[1] 0
Account(id = 1)
#> Error:
#> ! 1 validation problem in <Account>
#> @email expected chr[1][grepl("@", ., fixed = TRUE)], got <missing>The result is an ordinary S7 class, so S7 generics dispatch on it:
balance <- new_generic("balance", "x")
method(balance, Account) <- function(x) x@credit
balance(a)
#> [1] 10and an ordinary rdantic type, so it parses, validates and describes itself:
from_json(Account, '{"id": 2, "email": "bob@example.org"}')
#> <Account>
#> @ id : int 2
#> @ email : chr "bob@example.org"
#> @ credit: num 0
schema(Account)$properties$id
#> $type
#> [1] "integer"
#>
#> $description
#> [1] "satisfies . > 0"Properties are declared class_any, deliberately: the
real constraint is the rdantic type, and a second, weaker check by S7
would only be able to disagree with it. The field types are kept on the
class, which is why int[1][. > 0] survives above rather
than decaying to anything.
One deliberate difference from struct(): the name is
not registered, so ref() and
opt("Account") will not find it. A name in that registry
means something that builds instances, and this builds S7 objects. Pass
the class itself instead – it is a value, and a type.
Inheritance
.parent takes an S7 class, whose properties are
inherited as typed fields:
Premium <- s7_struct("Premium", tier = one_of("gold", "silver"), .parent = Account)
p <- Premium(id = 1, email = "ada@example.org", tier = "gold")
class(p)
#> [1] "Premium" "Account" "S7_object"
fields(Premium)
#> [1] "id" "email" "credit" "tier"There is one case rdantic can express and S7 cannot. rdantic lets a
child retype an inherited field; S7 keeps the parent’s
property definition and silently ignores a redeclaration. Rather than
hand back a class whose narrower type is never enforced, the hierarchy
is flattened – the type is honoured, the S7_inherits()
relationship is not – and you are told:
Big <- s7_struct("Big", id = int[1][. > 100], .parent = Account)
#> Warning: <Big> retypes `id`, which S7 cannot override on a parent class; the S7
#> class is flattened and does not inherit from <Account>
class(Big(id = 101, email = "ada@example.org"))
#> [1] "Big" "S7_object"
Big(id = 1, email = "ada@example.org")
#> Error:
#> ! 1 validation problem in <Big>
#> @id expected int[1][. > 100], got num[1] 1Converting an existing struct
When the struct already exists – because other code parses into it,
or it is reached by ref() – as_s7_class()
converts it rather than redeclaring it:
Animal <- struct("Animal", name = chr[1], legs = int[1])
Dog <- extend(Animal, "Dog", good = lgl[1] %default% TRUE)
d <- as_s7_class(Dog)(name = "Rex", legs = 4L)
class(d)
#> [1] "Dog" "Animal" "S7_object"
S7_inherits(d, as_s7_class(Animal))
#> [1] TRUEextend() becomes S7 inheritance, under the same
flattening rule as above. Unlike s7_struct(),
as_s7_class() caches by name and field signature, so
converting the same struct twice returns the same class and
S7_inherits() keeps working.
extend() and partial() also accept an S7
class directly, and read its properties as typed fields – so you can go
back to plain lists at any point:
In short
Use an S7 class directly when it is already your domain model and
rdantic belongs at the input boundary. Use s7_struct() when
you are declaring the model with rdantic types but downstream code
expects S7. Use as_s7_class() when the rdantic struct
already exists and you need an S7 view of it.