Tutorial
This tutorial covers the type checker: writing an annotation, what inference gives you without one,
unions and NULL, declaring your own domain types, generics, and strict mode. It assumes you can
already run ry check on a project.
1. Your first annotation
Section titled “1. Your first annotation”Here is a function with a type written above it:
#: fn(price: double, rate: double) -> doubleapply_discount <- function(price, rate) { price * rate}That first line is the annotation. It reads left to right: fn( the parameters and their types ),
then -> and the return type. It lives in a #: comment, so R and every other R tool see a comment
and carry on — annotated code is still ordinary R.
Now call it wrongly:
apply_discount(100, "0.2")error[type-mismatch]: expected `double`, found `character` --> t.R:6:216 | apply_discount(100, "0.2") ^^^^^Found without running anything, and pointing at the argument rather than at the line that would have blown up.
The annotation is a promise the checker holds you to in both directions. Claim the wrong return type and it says so, pointing at the body:
#: fn(price: double, rate: double) -> characterapply_discount <- function(price, rate) { price * rate}error[type-mismatch]: expected `character`, found `double` --> a.R:3:33 | price * rate ^^^^^^^^^^^^2. Inference
Section titled “2. Inference”Delete the annotation entirely and run it again:
apply_discount <- function(price, rate) { price * rate}
apply_discount(100, "0.2")error[type-mismatch]: expected `double`, found `character` --> t.R:5:215 | apply_discount(100, "0.2") ^^^^^The same error. * is arithmetic, so price and rate are numbers — the checker worked that out
from the body. This is inference, and it is why most R needs no annotations at all.
It is worth knowing what kind, because that decides how far you can trust it. ry uses Hindley–Milner inference, the algorithm behind ML, Haskell and Elm. Two properties matter here:
- It computes a principal type — the single most general type consistent with how a value is used. Not a guess, not a heuristic, not “the first thing that fit”. Run it twice and you get the same answer; run it on a colleague’s machine and they get yours.
- It does not silently accept a contradiction. Within the part of your program it can model, if the types cannot line up, you hear about it.
That second clause carries the honest limit. R has constructs no type system can follow — UseMethod
dispatch, data-frame columns, S4. There the checker yields Unknown rather than guessing, and
Unknown is compatible with everything, so one gap does not flood your screen with consequences.
Strict mode is how you find those gaps.
3. When to annotate
Section titled “3. When to annotate”Inference handles the interior. Annotate at the edges:
- Exported functions, and anything else another file or another person calls. The annotation is documentation the checker enforces, and it stops a change to the body quietly changing the contract.
- Where you want a promise held. If a function must return a
double, say so, and the day someone makes it return a list you find out immediately. - Where inference cannot see. A value that arrives from a data frame, a database, or
readRDS()isUnknown. If you know what it is, say so.
Not every local variable. Annotating n <- 1L adds nothing the checker did not already know.
4. NULL and narrowing
Section titled “4. NULL and narrowing”This is the error most likely to be a real bug in code you have already shipped:
#: fn(config: list{retries: integer} | NULL) -> integerretry_count <- function(config) { config$retries}error[type-mismatch]: expected a list, found `list{retries: integer} | NULL` --> a.R:3:33 | config$retries ^^^^^^^^^^^^^^The | makes a union: config is either that list or NULL. You cannot reach into it until you
have ruled out NULL. Add the guard and the error goes away:
#: fn(config: list{retries: integer} | NULL) -> integerretry_count <- function(config) { if (is.null(config)) return(3L) config$retries}1 file checked, no problemsThe checker follows the if: after the early return, config cannot be NULL any more, so the field
access is fine. That is called narrowing.
5. Domain types
Section titled “5. Domain types”Two doubles that must never be mixed up:
#: @type Celsius {double}
#: @type Fahrenheit {double}
#: fn(t: Celsius) -> Fahrenheitto_fahrenheit <- function(t) terror[type-mismatch]: expected `Fahrenheit`, found `Celsius`@type makes a name that is its own type, distinct from everything else even when the representation
is identical. This is the part no amount of inference can do for you — only you know that these two
numbers mean different things.
See domain modeling for constructors and validation.
6. Generics
Section titled “6. Generics”An unannotated function that constrains nothing is already generic:
identity2 <- function(x) xHover it and you get <T> fn(x: T) -> T — for any type T, takes one and returns the same one. Add
arithmetic and the promise narrows on its own to <T: numeric> fn(x: T) -> T. You asked for neither,
and there is nothing to maintain.
One caveat: a single T used twice means the same type twice. Against
<T: numeric> fn(value: T, factor: T) -> T, the call scale_by(2L, 0.5) is reported, because
integer and double are different Ts. Widen one side yourself when you want to mix.
7. Strict mode
Section titled “7. Strict mode”A clean run means “I found no contradictions”. It does not mean “I checked everything”. Strict mode
reports every place a value became Unknown:
[check]typing = truestrict = truesummarise <- function(frame) { frame$amount}error[strict]: strict mode: this expression has an undetermined type (`Unknown`) --> a.R:2:32 | frame$amount ^^^^^^^^^^^^Data-frame columns have no types yet, so that expression was never really checked — and without strict mode it looked like a pass. Turn this on for the modules you rely on, not across a legacy codebase on day one.
8. Adopting it a file at a time
Section titled “8. Adopting it a file at a time”You do not have to convert a project at once. A directive at the top of a file beats the project setting, in either direction:
# typing: on # check this file even if the project has typing off# typing: off # skip this one while you work through the rest# typing: strict # hold this module to the stronger standardAdopting an existing codebase has the order that works on a project with findings already in it.
Where to go next
Section titled “Where to go next”- Concepts — the full vocabulary: vectors, records,
AnyvsUnknown - Domain modeling — nominal types instead of S4, R6, or S7
- Limitations — where the checker cannot help yet
- Type system reference — the precise rules