Domain modeling
R’s object systems were designed for dispatch, not for static reasoning. This page shows what to use instead when what you want is for the checker to know what a value is.
What the object systems give a checker
Section titled “What the object systems give a checker”Very little. Here is an S4 class and an R6 class, and a function that only accepts strings:
setClass("Point", representation(x = "numeric"))pt <- new("Point", x = 1)
#: fn(s: character) -> characterwant_chr <- function(s) s
want_chr(pt@x)That is accepted. pt@x is declared "numeric" in the class definition, it is being passed where a
character is required, and no finding is reported, because the checker cannot see inside S4. Strict
mode reports it as an undetermined type:
strict
x strict mode: this expression has an undetermined type (`Unknown`) --[R/a.R:7:10] 6 | 7 | want_chr(pt@x) | ^^^^R6 behaves the same way. This gap will not close soon: both systems build their objects at runtime out of values the checker would have to execute to understand.
Declaring a type
Section titled “Declaring a type”#: @type Person {list{name: character, age: integer}}
#: @new Personada <- list(name = "Ada", age = 36L)@type declares a nominal type — a name that is its own type, distinct from everything else, even
things with an identical representation. @new is the one way a plain list becomes one, and it
checks the list against the declared shape as it goes.
From there the checker knows what ada is: ada$name is a character, ada$nam is a reported
mistake, and passing ada where a Person is wanted works while passing any other list does not.
Constructors
Section titled “Constructors”In practice you want one function that builds the value, so validation lives in one place:
#: @type Person {list{name: character, age: integer}}
#: fn(name: character, age: integer) -> Personnew_person <- function(name, age) { if (age < 0L) stop("age must not be negative") #: @new Person list(name = name, age = age)}new_person("Ada", "36")type-mismatch
x expected `integer`, found `character` --[R/a.R:10:19] 9 | 10 | new_person("Ada", "36") | ^^^^The @new is inside the constructor, so the constructor is the only way in: everything
downstream receives a Person, and the checker enforces that statically.
The two halves do different jobs. @new is an analysis-time check — it is not setValidity(),
not an R6 initialize(), and it emits no runtime assertion. It cannot know that age is
non-negative, and it cannot check a field whose type it could not determine. The stop() on the
line above is what enforces the invariant when the code runs. The stop() guards values at run
time, the @new guards shape at analysis time, and both sit in the one function every caller goes
through.
Distinct types that look identical
Section titled “Distinct types that look identical”This is what a structural checker cannot do for you:
#: @type Celsius {double}
#: @type Fahrenheit {double}
#: fn(t: Celsius) -> Fahrenheitto_fahrenheit <- function(t) ttype-mismatch
x expected `Fahrenheit`, found `Celsius` --[R/a.R:6:30] 5 | #: fn(t: Celsius) -> Fahrenheit 6 | to_fahrenheit <- function(t) t | ^Both are double underneath, and neither is interchangeable with the other. Two character ids,
two currencies, a validated email beside an unvalidated string — anywhere your domain has values
that are the same shape but must not be mixed, this is how you say so.
The representation still leaks outward: a Celsius is accepted anywhere a plain double is
expected, so t / 2 and mean(temps) keep working. The reverse does not hold — a bare double
never becomes a Celsius on its own. That asymmetry is deliberate: arithmetic stays convenient,
while the only way into the type is a @new you wrote.
If you want the opposite — a name that is pure shorthand and stays interchangeable with its body —
that is @alias, not @type.
Types with a parameter
Section titled “Types with a parameter”#: @type Box<T> {list{value: T}}A Box<integer> and a Box<character> are different types, and the element type flows out again
when you read it.
When to use R6
Section titled “When to use R6”Nominal types describe values. They do not give you:
- Mutable state with reference semantics. An R6 object shared between callers and mutated in place is a thing this cannot express.
- Inheritance hierarchies. There is no subtyping between nominal types.
- Dispatch. If you need
print()andsummary()to do the right thing per class, that is S3. Those go throughUseMethod, which dispatches at run time — the checker types such calls asUnknownand cannot follow them. (S3 operator dispatch,+.Dateand friends, is resolved statically; method dispatch is not.)
Use a nominal type when you have a value with a shape and an invariant, which is most of what an analysis codebase passes around. Use R6 when you have an object with identity and mutable state, and accept that its interior is opaque to the checker.
Plenty of R code uses R6 for things that are really just values. Those are the ones worth converting.
- Concepts — nominal versus structural, and the rest of the vocabulary
- Limitations — the full picture on object systems
- Type system reference — the exact rules for
@type,@new, and@alias