Type your code
Goal: find type errors in plain R before it runs. You switch type checking on, read what inference finds, and add annotations where code meets other code. The checker never runs your code, and the annotations are comments, so the code keeps running on GNU R.
Switch it on
Section titled “Switch it on”[check]typing = trueA # typing: on comment at the top of a file switches it on for that file alone, and
# typing: off switches it off, so you can adopt types one file at a time.
Inference
Section titled “Inference”ry infers types from how values are used, so unannotated code is checked as it is:
apply_discount <- function(price, rate) price * (1 - rate)apply_discount(100, "0.2")type-mismatch
x expected a numeric value (`integer` or `double`), found `character` --[R/discount.R:2:21] 1 | apply_discount <- function(price, rate) price * (1 - rate) 2 | apply_discount(100, "0.2") | ^^^^^- and * need numbers, so both parameters must be numbers. Each function gets the most general
type its body allows, which the editor shows on hover:
identity2 <- function(x) x # <T> fn(x: T) -> Tincrement <- function(x) x + 1L # <T: numeric> fn(x: T) -> TT is any type, and T: numeric is integer or double, so increment(2.5) is a double and
increment("a") is an error. Argument names are part of a function’s type, because R matches
arguments by name as often as by position:
greet <- function(name, greeting = "hello") paste(greeting, name)greet("Ada", greting = "hi")type-mismatch
x this function has no parameter `greting`. Did you mean `greeting`?Annotate
Section titled “Annotate”Inference cannot know what a file holds or what a function promises. A #: comment directly above
an assignment states it, and every use of the value is checked against it. In the Untyped tab,
settings could be anything, so ry reports nothing:
settings <- readRDS("settings.rds")
should_retry <- function(attempt) attempt < settings$retires#: list{retries: integer, verbose: logical}settings <- readRDS("settings.rds")
#: fn(attempt: integer) -> logicalshould_retry <- function(attempt) attempt < settings$retirestype-mismatch
x field `retires` does not exist in `list{retries: integer, verbose: logical}`. Did you mean `retries`?settings: list{retries: integer, verbose: logical} <- readRDS("settings.rds")
should_retry <- function(attempt: integer) -> logical attempt < settings$retirestype-mismatch
x field `retires` does not exist in `list{retries: integer, verbose: logical}`. Did you mean `retries`?The Inline tab writes each type next to its name. It means the same to the checker, but GNU R cannot parse it, so it runs only through ry (step 3). Your tab choice holds on every page.
The body is checked against the signature too: a function declared -> logical that returns a
string is an error at the return value. [name] in a #: signature marks a parameter callers may
omit, as in #: fn(name: character, [greeting]: character) -> character. Annotate where code meets
other code: exported functions, values read from files, and any contract you want enforced.
Inference covers the rest.
Shapes
Section titled “Shapes”R has no scalars, and one list type serves as record, tuple and dictionary alike. ry’s types tell these apart without any change to your code:
| Type | Holds | Example |
|---|---|---|
integer |
exactly one element | 1L |
integer[] |
any length | c(1L, 2L) |
integer[named] |
any length, with names | c(a = 1L) |
list{name: character, age: integer} |
fixed fields | list(name = "Ada", age = 36L) |
list{integer, character} |
fixed positions | list(1L, "ok") |
list[integer] |
any length | lapply(words, nchar) |
list[named: integer] |
looked up by key | [[key]] gives integer | NULL |
character | NULL |
either | NULL default |
double accepts integer and logical, but a number never passes for character. A fixed shape
makes indexing checkable, where R returns NULL for a misspelled field and stops only at run time
for a position past the end:
result <- list(200L, "ok")result[[3]]type-mismatch
x position 3 does not exist in `list{integer, character}`A value that may be NULL has a union type, and using it as if it were not is an error:
#: fn(config: list{retries: integer} | NULL) -> integerretries <- function(config) { config$retries}retries <- function(config: list{retries: integer} | NULL) -> integer { config$retries}type-mismatch
x expected `integer`, found `integer | NULL`An is.null() test narrows the type for the rest of the function, so adding
if (is.null(config)) return(3L) as the first line clears the error. is.character(),
is.numeric() and the other is.* tests narrow the same way.
Unknown
Section titled “Unknown”Values ry cannot model have the type Unknown, which fits everywhere, so one unmodeled construct
never causes a cascade of false errors. The main sources are base data.frame columns, S4 and R6
objects, and packages ry has no stub for. An annotation gives such a value a type, as settings
got one above, and every later use is checked against it.
ry ships types for base R, the default packages, data.table, dplyr, ggplot2 and testthat,
and export lists for the rest of the tidyverse and other common packages. For any other package, a
stub file declares what you call, and turns the unresolved-name check back on:
@type Sessionconnect : fn(host: character) -> SessionAuthoring stubs has the format. A clean run does not say how much
was Unknown; strict mode does.
Limitations lists what is not checked.
Next: run on ry’s runtime.