Skip to content

Concepts

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.

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:

NotationMeans
integerAn 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.

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 < complex

So 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 buildingTypeExample
A record with named fieldslist{name: character, age: integer}list(name = "Ada", age = 36L)
A fixed-size tuplelist{integer, character}list(1L, "ok")
A growable sequence, all one typelist[integer]results you append() to in a loop
A lookup table keyed by namelist[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$fullnme

The 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
| ^^^^^^^

A function type is written in the order you would say it:

fn(x: integer, y: character) -> logical

Optional 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.

A value that could be one of several things has a union type, written with |:

integer | character

You 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.

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
UnknownThe checker could not work it out. A gap in its knowledge
AnyYou 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.

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) -> Person
new_person <- function(name, age) {
#: @new Person
list(name = name, age = age)
}
#: fn(p: Person) -> character
greet <- 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 Person
ada <- 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.

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) -> Fahrenheit
to_fahrenheit <- function(temp) {
#: @new Fahrenheit
temp * 9 / 5 + 32
}
#: @new Celsius
freezing <- 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.

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.

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.

You rarely write these, and you get them anyway. An unannotated function whose parameters nothing constrains is generic automatically:

identity2 <- function(x) x

is <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 + 1L

is <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) -> T

There are three, and they are per file as well as per project:

ModeReports
offNo type findings. Everything else still runs
onContradictions — places where the types genuinely disagree
strictContradictions, 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: strict

The file always wins, which is what lets you adopt this one module at a time.