Skip to content
ry

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.

ry.toml
[check]
typing = true

A # 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.

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) -> T
increment <- function(x) x + 1L # <T: numeric> fn(x: T) -> T

T 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`?

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

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.

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) -> integer
retries <- function(config) {
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.

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:

stubs/dbclient.Rtypes
@type Session
connect : fn(host: character) -> Session

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