Skip to content
ry

Vectors, lists and NULL

Three of R’s most common runtime errors come from shapes: a vector of length two where an if expects one value, a misspelled list field that silently reads as NULL, and a NULL that nobody handled. ry’s types record all three, so it reports them before the code runs, while your code stays the same R.

R has no scalars: 1L is an integer vector of length one. But an if condition, an operand of &&, and an endpoint of : need exactly one element, and passing a longer vector there is a bug R reports late or not at all. So ry tracks the length-one case as its own type:

n <- 1L # integer: exactly one element
ids <- c(1L, 2L) # integer[]: any length
ages <- c(ada = 36L, bo = 4L) # integer[named]: any length, with names

A scalar is accepted where a vector is expected, but not the reverse, because a vector of unknown length may not have exactly one element:

#: fn(flag: logical) -> character
label <- function(flag) if (flag) "yes" else "no"
label(c(TRUE, FALSE))
type-mismatch
x expected `logical`, found `logical[]`

A double accepts an integer or a logical, which R converts without loss. A number is never accepted where character is expected: R would convert it, but a silent number-to-string conversion is usually a bug, so ry asks for an explicit as.character().

R uses one list type for four different jobs, and each job has its own mistakes, so ry gives each its own type. Braces mean a fixed shape (list{...}), and brackets mean any length (list[...]). At run time all four are the same R list, so your code does not change.

pair <- list(1L, "ok") # positional: list{integer, character}
person <- list(name = "Ada", age = 36L) # named fields: list{name: character, age: integer}
sizes <- lapply(words, nchar) # any length: list[integer]
#: list[named: integer]
counts <- list(apples = 3L) # by key: list[named: integer]

A list of positional values or named fields has a fixed shape, so ry knows each position and field and its type. That makes $ checkable, which matters because R answers a misspelled field with a silent NULL that fails somewhere else:

person$nmae
type-mismatch
x field `nmae` does not exist in `list{name: character, age: integer}`. Did you mean `name`?
--[R/person.R:2:8]
2 | person$nmae
| ^^^^

A fixed shape is also exact, so a list with an extra field where a fixed shape is expected is an error, because an unexpected field is usually a misspelled one.

A list of any length, or one looked up by key, can have any length, so ry knows only its element type. Reading a key gives the element type or NULL, because the key may be missing:

counts[["pears"]] # integer | NULL

A list(...) literal infers as a fixed shape, so a dictionary that starts from a literal needs an annotation, as counts does. An empty list() filled by computed keys, as in d[[key]] <- value, needs none.

Many R functions return NULL for “nothing”, and code that forgets the case fails far from where the NULL came from. A value that may be NULL has a union type, such as character | NULL.

The most common case is an optional argument that defaults to NULL. Its type says what a caller may pass instead. In the fn(...) form, brackets around a name such as [label] mark an optional argument:

#: fn(id: integer, [label]: character | NULL) -> character
label_for <- function(id, label = NULL) {
if (is.null(label)) paste("item", id) else label
}
label_for(1L, label = 2)
type-mismatch
x expected `character | NULL`, found `double`

A field of a value that may be NULL may be NULL too, because R reads every field of NULL as NULL. Returning such a field where an integer is declared is an error:

#: fn(config: list{retries: integer} | NULL) -> integer
retries <- function(config) {
config$retries
}
type-mismatch
x expected `integer`, found `integer | NULL`

Check for NULL first, and the error goes away:

#: fn(config: list{retries: integer} | NULL) -> integer
retries <- function(config) {
if (is.null(config)) return(3L)
config$retries
}

The guard narrows config to the list for the rest of the function. is.character(), is.numeric(), is.list(), and the other is.* tests narrow the same way.

Narrowing applies to variables only, so to test a field, copy it into a variable first: r <- config$retries; if (is.null(r)) ....

ry checks the NULL you declare. A variable that the body computes without an annotation, such as the result of a switch without a default, is passed on as its value without the NULL, because the code presumably never reaches the call on the NULL path. The same holds for the NULL of an unannotated parameter, from a NULL default or an is.null() test. To have such a value checked, annotate the assignment or the function.