Skip to contents

s7contract describes what a consumer needs from an S7 object and tests whether implementations behave as expected. S7 provides class definitions, method registration, and dispatch.

Structural interfaces and explicit traits address the need described in the S7 traits discussion: checking method contracts around existing generics. Go interfaces inform the structural approach; Rust traits inform explicit registrations, defaults, and associated metadata. Here these are runtime R facilities. Checked calls and generative laws extend them from method availability to evidence about behavior.

Mechanism Question
S7 properties and validators Is the object’s representation valid?
implements() Can S7 find the required methods?
has_trait() Has this implementation been declared?
with() / %::% Do this call’s arguments and return value satisfy their specifications?
check_law() Does a behavioral claim hold over the generated cases?

A vector protocol

A windowing function needs length, slicing, and access to values. VectorLike states those requirements. Both double vectors and ReadDepth objects provide the methods; the ReadDepth validator keeps positions and depths aligned.

vec_length <- new_generic("vec_length", "x")
vec_slice <- new_generic("vec_slice", "x", function(x, i) S7_dispatch())
vec_values <- new_generic("vec_values", "x")

VectorLike <- new_interface(
  "VectorLike",
  generics = list(
    length = interface_requirement(vec_length, returns = class_integer),
    slice = interface_requirement(vec_slice, args = list(i = class_integer)),
    values = interface_requirement(vec_values, returns = class_double)
  )
)

ReadDepth <- new_class(
  "ReadDepth",
  properties = list(position = class_integer, depth = class_double),
  validator = function(self) {
    if (length(self@position) != length(self@depth)) {
      "@position and @depth must have the same length"
    }
  }
)

method(vec_length, ReadDepth) <- function(x) length(x@depth)
method(vec_slice, ReadDepth) <- function(x, i) {
  ReadDepth(position = x@position[i], depth = x@depth[i])
}
method(vec_values, ReadDepth) <- function(x) x@depth

method(vec_length, class_double) <- function(x) length(x)
method(vec_slice, class_double) <- function(x, i) x[i]
method(vec_values, class_double) <- function(x) x

coverage <- ReadDepth(position = 1:5, depth = c(12, 15, 9, 20, 17))
implements(coverage, VectorLike)
#> [1] TRUE
implements(class_double, VectorLike)
#> [1] TRUE

The consumer uses the protocol without depending on either representation:

window_mean <- function(x, i) {
  assert_implements(x, VectorLike)
  with(VectorLike, mean(vec_values(vec_slice(x, i))))
}

window_mean(coverage, 2:4)
#> [1] 14.66667
window_mean(c(12, 15, 9, 20, 17), 2:4)
#> [1] 14.66667

assert_implements() checks method availability. Inside with(VectorLike, ...), calls also check the argument and return specifications declared by the interface. For example, its slice operation requires integer indices:

tryCatch(
  with(VectorLike, vec_slice(coverage, "first")),
  error = function(e) conditionMessage(e)
)
#> [1] "`i` must be <integer>, not <character>"

Composing contracts

Embedding retains every parent requirement. Reusing the same requirement through several parents is valid:

Readable <- new_interface("Readable", interface_requirements(VectorLike)["values"])
ReadableView <- new_interface("ReadableView", parents = Readable)
Combined <- new_interface("Combined", parents = list(Readable, ReadableView))
names(interface_requirements(Combined))
#> [1] "values"
implements(coverage, Combined)
#> [1] TRUE

Aliases and generic names share the checked-call namespace. Overlapping names must describe the same generic and type specifications; incompatible declarations are rejected rather than choosing a parent by its position:

tryCatch(
  new_interface("Conflicting", list(values = vec_length), parents = Readable),
  error = function(e) conditionMessage(e)
)
#> [1] "Conflicting requirements for name `values`."

Trait composition also requires identical defaults for overlapping methods. An associated type or constant has one declaring trait. A diamond may share that trait, but two independent supertraits cannot declare the same associated name in the same category. Inherited values come from the declaring trait’s current implementation.

Declaring an implementation

Use a trait when a declaration or associated metadata matters to the consumer. Here the declaration attaches measurement units to ReadDepth:

Measured <- new_trait("Measured",
  methods = list(values = trait_method(vec_values)),
  assoc_consts = "UNITS"
)
has_trait(ReadDepth, Measured)
#> [1] FALSE

impl_trait(Measured, ReadDepth,
  methods = list(values = function(x) x@depth),
  assoc_consts = list(UNITS = "reads"),
  replace = TRUE
)
has_trait(ReadDepth, Measured)
#> [1] TRUE
trait_assoc_const(Measured, ReadDepth, "UNITS")
#> [1] "reads"

Trait registration validates methods with S7 before publishing them. Methods and associated values form one registration: rejected registrations leave its method bindings and implementation record unchanged. Aliases for the same generic must provide identical implementation functions.

Conformance also works for supported base classes, S3 wrappers and S4 classes:

day_number <- new_generic("day_number", "x")
CalendarDay <- new_trait("CalendarDay", list(
  day = trait_method(day_number, returns = class_double)
))
impl_trait(CalendarDay, class_Date, list(day = function(x) as.double(x)))
date <- as.Date("2026-01-01")
has_trait(date, CalendarDay)
#> [1] TRUE
with(CalendarDay, day(date))
#> [1] 20454

Structural checks follow method inheritance. Nominal checks require registration for the object’s own class, so a subclass does not acquire its parent’s trait merely by inheriting its methods.

Testing behavior

Method availability and valid return types leave semantic claims untested. This law checks length against the values used to construct the object:

length_law <- new_law("length matches constructor input",
  generators = list(values = gen_vector(gen_double(-10, 10), max = 6L)),
  holds = function(values) {
    x <- ReadDepth(position = seq_along(values), depth = values)
    with(VectorLike, identical(vec_length(x), base::length(values)))
  }
)
check_law(length_law, tests = 100L, seed = 1L)
#> Law 'length matches constructor input' was falsified after 1 attempts and 0 shrinks (seed 1).
#> The law returned FALSE.
#> Shrinking stopped: no child of this counterexample preserves the failure.
#> Smallest counterexample found:
#> List of 1
#>  $ values: num(0)

The vector law suite runs four laws against both representations and finds a faulty slice method that still satisfies the interface. Its generators preserve valid objects and indices while shrinking.

For generator composition, replay, and tinytest integration, see Generative Laws with tinytest. The Maybe dictionary shows function-valued operations; Testing Stateful S7 Protocols covers sequences of mutations checked against a reference model.