library(rdantic)
#>
#> Attaching package: 'rdantic'
#> The following object is masked from 'package:graphics':
#>
#> frame
#> The following object is masked from 'package:base':
#>
#> datefn() declares a function whose arguments and return
value are both checked. Types sit where R would put defaults; an unnamed
formula ~ T declares the return type; the last unnamed
expression is the body.
Basics
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")) # a vector where one value was meant
#> Error:
#> ! 1 validation problem in greet()
#> name expected chr[1], got chr[2] -- length is 2, not 1%default% supplies a value used when an argument is
omitted, exactly as it does for a struct field
(vignette("structs")); an argument whose type accepts
NULL may also be omitted.
The return value is checked too
A typed function cannot leak a wrong result. An explicit
return() goes through exactly the same check as a value
that falls through at the end of the body:
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 implicitlyReserved names and typed ...
An argument name that collides with rdantic’s own internal
bookkeeping (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 every value passed through it.
Values passed via ... are checked but not coerced
(... cannot be rebound), and every bad argument is reported
in one error, as with a struct:
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] 3Turning checks off
Checks are compiled once, when the function is defined, and cost tens of microseconds per call – nothing at an API boundary, but possibly noticeable in a tight inner loop. Turn them off globally when that cost matters more than the safety:
Composing typed functions
typed_fn accepts any function created by
fn(). More often, higher-order code needs a particular
signature: fn_type(o = Order, ~ Money) accepts only a typed
function with that declared argument and return type.
Order <- struct("Order",
id = int[1][. > 0],
qty = int[1][. > 0],
unit_price = num[1][. >= 0],
coupon = num[1][0 <= . & . <= 1] %default% 0
)
order <- Order(id = 1042, qty = 3, unit_price = 19.99)
Money <- num[1][. >= 0]
pricing_rule <- fn_type(o = Order, ~ Money)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.97Signature checks include argument names and defaults. They compare
declarations rather than calling the function, so no user code runs
during validation. Use the broader typed_fn type when any
declared signature is acceptable.
The clearance rule is well-typed on the way in and wrong
on the way out (a negative total). 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 places the same pipeline can break – each 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 <fn> (o: Order) -> num[1][. >= 0], got function[1]And a factory that forgets to return a function is caught by its own return type, before anyone can call the result: