Concepts
Inference
Section titled “Inference”Inference is the checker working out a type instead of being told one. What a value must be follows from what you do with it.
scale <- function(x, factor) { x * factor}* is arithmetic, so x and factor are numbers. That is inferred, not declared, and it is enough
to reject scale("a", 2).
You annotate where you want to state something the code does not already state — a boundary, a domain type, a promise you want held to.
Scalars and vectors
Section titled “Scalars and vectors”In R, 1L is an integer vector of length one. There is no separate scalar type, so the type system
has to choose a convention. ry’s is:
| Notation | Means |
|---|---|
integer | An integer vector of length one |
integer[] | An integer vector of unknown length |
integer[named] | An integer vector of unknown length, keyed by names |
Literals are scalars. 1L is integer, not integer[].
The relationship between them is one-way: a scalar is accepted where a vector is expected, because a length-one vector is a vector. The reverse is not, because a vector of unknown length might have length zero, or seven.
Atomic types
Section titled “Atomic types”The atomic types are logical, integer, double, complex, character, and raw.
Four of them form a ladder, and values widen up it implicitly:
logical < integer < double < complexSo an integer is accepted where a double is wanted. character and raw are deliberately
not on the ladder: R coerces a number to a string, but doing so silently is more often a mistake
than an intention, so ry requires you to write the conversion.
Widening happens only when checking whether a value fits somewhere. It never happens when the checker is working out what two things have in common — that is a different question, and answering it by widening would discard information.
R gives you one list() and expects you to use it for everything. In practice it does at least four
different jobs, and the mistakes available in each are different:
| What you are building | Type | Example |
|---|---|---|
| A record with named fields | list{name: character, age: integer} | list(name = "Ada", age = 36L) |
| A fixed-size tuple | list{integer, character} | list(1L, "ok") |
| A growable sequence, all one type | list[integer] | results you append() to in a loop |
| A lookup table keyed by name | list[named: integer] | counts by category |
The distinction that matters is fixed shape versus unknown shape. list{...} means the
checker knows exactly which elements exist, so it can check $ and [[. list[...] means it knows
the element type but not how many there are, so it cannot.
Atomic vectors have the same two shapes: integer is one value, integer[] is many.
None of this changes anything at runtime. All four are an ordinary R list; is.list() is
TRUE for every one, str() prints what it always printed, and there is nothing to migrate. The
distinction exists so the checker can tell you which of the four you built.
That is what makes $ checkable:
person <- list(fullname = "Ada", age = 36L)person$fullnmeThe literal has a fixed shape, so the checker knows the field is not there, instead of the silent
NULL you get at runtime:
type-mismatch
x field `fullnme` does not exist in `list{fullname: character, age: integer}`. Did you mean `fullname`? --[R/a.R:2:8] 1 | person <- list(fullname = "Ada", age = 36L) 2 | person$fullnme | ^^^^^^^Functions
Section titled “Functions”A function type is written in the order you would say it:
fn(x: integer, y: character) -> logicalOptional parameters — those with defaults — are marked, and parameter names are part of the type, because R calls functions by name as often as by position.
Unions and NULL
Section titled “Unions and NULL”A value that could be one of several things has a union type, written with |:
integer | characterYou have already seen one: when a variable is assigned different types on different branches, its
type after the if is the union of both. That is what the type checker reports in the
Features example.
NULL is its own type, and this is where unions matter most. A function that may return nothing has
type character | NULL, and using that result as a character without checking is an error — the
missing if (is.null(x)) that would have failed at runtime.
Any and Unknown
Section titled “Any and Unknown”Both accept anything and are accepted anywhere. They exist as two names because the reason they arise differs, and the reason is what you need to know:
| Means | |
|---|---|
Unknown | The checker could not work it out. A gap in its knowledge |
Any | You declared that it does not matter. A deliberate opt-out you wrote |
Because Unknown is compatible with everything, one construct the checker cannot model does not
cascade into a screen of errors; it produces no findings at all. That is what keeps the tool usable
on real R. It also means a clean run does not by itself tell you how much was checked.
Strict mode addresses that: it reports every place a value became Unknown, so the
gaps are visible instead of looking like approval.
Naming your own types
Section titled “Naming your own types”Start with the common case: a thing in your domain with named fields. In R you would reach for S4, R6 or S7. ry offers a third option that the checker can see into:
#: @type Person {list{name: character, age: integer}}
#: fn(name: character, age: integer) -> Personnew_person <- function(name, age) { #: @new Person list(name = name, age = age)}
#: fn(p: Person) -> charactergreet <- function(p) paste0("hi ", p$name)A Person is an ordinary named list at runtime — no class system, no dispatch, no dependency. To
the checker it is a distinct type, so greet accepts a Person and nothing else:
greet(list(name = "Bob", age = 40L))type-mismatch
x expected `Person`, found `list{name: character, age: integer}` --[R/a.R:12:7] 11 | 12 | greet(list(name = "Bob", age = 40L)) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^The list has exactly the right fields with exactly the right types, and it is still rejected.
Matching the shape is not sufficient; the value has to have been introduced as a Person:
#: @new Personada <- list(name = "Ada", age = 36L)@new is the only way in. It checks the value against the declared representation and returns the
nominal type. Put it inside a constructor, as new_person does, and every Person in your program
came from there.
This is nominal typing, as opposed to structural. Structural typing asks “does it have the
right shape?”; nominal typing asks “is it the thing?”. S4 and R6 answer that question at run time
and tell the checker nothing. @type answers it at analysis time and adds nothing at run time.
Two names for the same shape
Section titled “Two names for the same shape”Nominal types matter most when two values have the same shape but must not be substituted for each other:
#: @type Celsius {double}
#: @type Fahrenheit {double}
#: fn(temp: Celsius) -> Fahrenheitto_fahrenheit <- function(temp) { #: @new Fahrenheit temp * 9 / 5 + 32}
#: @new Celsiusfreezing <- 0
warm <- to_fahrenheit(freezing)to_fahrenheit(warm)type-mismatch
x expected `Celsius`, found `Fahrenheit` --[R/a.R:15:15] 14 | warm <- to_fahrenheit(freezing) 15 | to_fahrenheit(warm) | ^^^^The last line converts an already-converted temperature. Both types are double underneath, and
arithmetic on them still works, because the representation is visible outward. What cannot happen
is a bare double, or the other unit, arriving where one of them is expected.
@alias
Section titled “@alias”Sometimes you want the opposite: a shorthand that stays interchangeable with what it abbreviates.
#: @alias Row {list{id: integer, label: character}}Row expands to its body everywhere, so a Row and that list are the same type. Use it to avoid
repeating a long type; use @type when being confused with the underlying value is what you are
trying to prevent.
See domain modeling for constructors, validation, and when R6 is still the right answer.
Narrowing
Section titled “Narrowing”Inside an if, the checker learns from the condition:
greet <- function(name) { if (is.null(name)) { return("hello, stranger") } paste0("hello, ", name)}After the guard, name is no longer NULL and paste0 accepts it.
Narrowing is deliberately limited. It happens on the condition of an if, and only for a guard
applied directly to a plain variable. if (is.null(x)) narrows x; if (is.null(obj$field)) does
not, and neither does a guard behind an &&. If a narrowing you expected did not happen, this is
almost always why — lift the value into a local variable first.
Generics
Section titled “Generics”You rarely write these, and you get them anyway. An unannotated function whose parameters nothing constrains is generic automatically:
identity2 <- function(x) xis <T> fn(x: T) -> T — it returns exactly what it was given, whatever that was. Add one arithmetic
operation and the checker narrows the type on its own:
increment <- function(x) x + 1Lis <T: numeric> fn(x: T) -> T: some number in, the same kind of number out. Neither has to be
asked for, and neither has to be maintained.
When you do write one, the binder goes at the front:
#: <T> fn(items: list[T], index: integer) -> TTyping modes
Section titled “Typing modes”There are three, and they are per file as well as per project:
| Mode | Reports |
|---|---|
off | No type findings. Everything else still runs |
on | Contradictions — places where the types genuinely disagree |
strict | Contradictions, plus every place a value became Unknown |
Set the project default in ry.toml, and override it in any single file
with a comment at the top:
# typing: strictThe file always wins, which is what lets you adopt this one module at a time.
- Tutorial — the same ideas, applied to real code
- Domain modeling — nominal types instead of S4, R6, or S7
- Limitations — where the checker cannot help yet
- Type system reference — the exact contract