Skip to content
ry

Authoring stubs

This page is for contributors who edit the shipped stub corpus, and for anyone writing a project’s own stubs. The corpus lives in the repository’s top-level types/ directory: about 1,700 typed declarations in 77 .Rtypes files, plus an .exports manifest for every namespace R ships and for every CRAN package in the benchmark dependency set.

Shipping a package’s types means dropping types/<namespace>.Rtypes (and its manifest) into that directory and nothing else: crates/semantics/build.rs embeds every file it finds there. R’s own packages load first, in search-path order, then the CRAN namespaces alphabetically, then ry’s standard library. Every namespace that is not one of R’s own packages or the standard library is conditional, so its names exist only in projects that use the package. The typed declarations of R’s packages that R does not attach by default (tools, parallel, compiler, grid, splines, stats4, tcltk) are gated the same way, while their export manifests always answer pkg::name. The typing rules the stubs feed are in the typing reference, and the user-facing introduction is Stubs.

The checker has only a small kernel built in: the operators, plus c and list. Everything else in the standard library, from T and pi to length, paste, and seq_len, gets its type from a stub: a declaration-only description of a package, loaded as an immutable input.

A stub file has the extension .Rtypes (“R type information”), which echoes R’s .Rd without colliding with .Rd, .Rmd, .Rda, or .RData. Every line that is not blank or a comment is one declaration:

name : <type-expr>

The type expression uses exactly the #: annotation grammar, read by the same parser, so there is no second notation to keep in sync. A stub file has nowhere to write a function body, which makes “declaration-only” a structural fact, as it is for TypeScript’s .d.ts and Python’s .pyi files. Blank lines and # comments, whole-line or trailing, are ignored, with one exception: consecutive #' lines directly above a declaration are its documentation, which hover and completion show before the signature. A later source that declares the same name replaces that documentation, or clears it when its own declaration has none.

#' Applies `f` to each element.
List.map : <A, B> fn(list: list[A], f: fn(A) -> B) -> list[B]

A declared name can be an R identifier, an infix operator such as %in% (or a project’s own %||%), or an S3 operator method such as +.Date, -.POSIXct, Arith.difftime, Compare.Date, or Ops.gg, or a subset method such as [.xts, [[.Date, or $.request, which types x[i], x[[i]], or x$name on that class. An object whose members are reached with $, such as a polars frame or another environment-backed object, declares each member as Class$member: polars_data_frame$select : fn(...: Any) -> polars_data_frame types frame$select(...), and polars_data_frame$height : integer types a property. A member the stub does not list stays Unknown. The operator spellings are how a stub gives a class arithmetic or comparison, and operator methods on a class describes the dispatch order. The suffix is the nominal type’s own name, so a project stub line +.ggplot : fn(e1: ggplot, e2: Any) -> ggplot is all a +-based DSL needs.

# base.Rtypes, a fragment
# plain value bindings
T : logical
F : logical
pi : double
# a fixed-arity function signature
length : fn(x: Any) -> integer
# a parametric higher-order function, a real generic scheme rather than Any
lapply : <T, U> fn(X: list[named: T], FUN: fn(T) -> U, ...: Any) -> list[named: U]

Besides values, a stub can declare a type. A line @type NAME declares an opaque nominal type: a named type with no inspectable representation, for standard-library values the type grammar cannot describe structurally, such as factor, connection, or Date.

An opaque nominal is compatible only with itself, and a value of it can only come from a function declared to return it. So Sys.Date() is a Date, and passing it where a factor is expected is a type error. The functions that consume such values usually keep Any parameters, because R coerces liberally; the nominals exist to tighten return types. Using $, [, or [[ on an opaque nominal gives Unknown rather than an error, so value-dependent access stays usable, and strict mode flags each such access. The typing reference has the full rules.

An opaque type may take type parameters, as @type Dict<K, V> does in types/std.Rtypes. Its parameters are invariant, so a Dict<character, double> is neither a Dict<character, Any> nor the other way around, and Dict<K, V> in a signature binds both parameters at a call.

A stub can also declare a structural alias, written exactly like a project’s #: @alias:

@alias Result<T, E> {:Ok(T) | :Err(E)}

The alias then works in every annotation, as Result<integer, :NotFound>.

A stub type may also declare a representation, written @type NAME {REPRESENTATION} exactly like a project’s #: @type. types/base.Rtypes declares @type data.frame {list[named: Unknown]}, so a data frame passes wherever a named list is expected, as lapply(frame, f) needs, and $ and [[ read its columns as Unknown, which strict mode reports at the read. Unlike a project’s own nominal type, a mismatch against a stub type does not suggest @new, because its values come from their own constructors such as read.csv(). Declare the class’s operator and [ methods beside it when R keeps or changes the class, as [.data.frame and Arith.data.frame do: dispatch looks at the declared class before the representation.

A type name may contain an interior dot, as data.frame does, and a project’s own @type or @alias of the same name shadows the stub’s type. A type declaration that does not load, such as a @type line whose name is not a plain identifier or whose representation does not parse, is reported as an error on its own line, and its name stays unknown to the rest of the file.

A declaration may put the @masked attribute in front of its function type:

summarise : @masked fn(.data: Any, ...: Any) -> Any

This marks a function that evaluates the arguments it collects in ... inside a data mask, the dplyr style of non-standard evaluation. A bare name in one of those positions is treated as a column reference and raises no unresolved-name warning, while an argument that matches a declared formal resolves normally. @masked requires a variadic function type. The typing reference specifies the details under data frames and non-standard evaluation.

The @pure attribute declares that calling the function has no effect beyond returning a value, whatever its arguments:

paste0 : @pure fn(...: Any, [sep]: character, [collapse]: character | NULL, [recycle0]: logical) -> character

Under strict mode, a constant at the top level of a package file may call only pure functions (see a package file’s top level). Purity belongs to the name: marking any candidate of an overload set marks the name, and a later source that redeclares the name without the marker clears it. @pure and @masked may appear in either order. Mark a function only when no argument can make it read or change state outside its result: cat, Sys.time and options are not pure, and neither is a function that calls back into an argument it was given, such as lapply.

Declaring the same name more than once in one file builds an ordered overload set, with each further declaration appending a candidate:

sum : fn(...: integer[] | logical[], [na.rm]: logical) -> integer
sum : fn(...: double[] | integer[] | logical[], [na.rm]: logical) -> double
sum : fn(...: difftime, [na.rm]: logical) -> difftime
sum : fn(...: data.frame | frame | matrix, [na.rm]: logical) -> double
sum : fn(...: complex[] | double[] | integer[] | logical[], [na.rm]: logical) -> complex

A call commits to the first candidate that accepts its arguments, so sum(1L, 2L) is integer, sum(1L, 2.5) is double, and sum("a") is an error, as it is in R. The typing reference specifies the selection rules under overload sets.

Order each set from most specific to most general, and end it with the most general candidate, because that is also what the name resolves to when it is used without being called. Write the candidates so that together they accept exactly what R accepts. An Any fallback is the escape hatch for a function whose accepted inputs no candidate list can describe, but it also accepts every input R rejects, so sum and the other numeric reductions do without one. A wrapper such as function(x) sum(x) then leaves its parameter Unknown, which is unchecked, rather than generic. A project override that redeclares a name replaces the whole set, so an override that wants overloads must declare all of them itself.

Notice where the rest parameter sits in those three lines. A parameter declared before ... can be filled by position, so [na.rm] has to come after it, or sum(1L, 2L) would bind 1L to na.rm and fail.

The editor shows the whole set. Signature help on a call lists every candidate, with the committed one active and filled in with that call’s types, and the others showing their declared type parameters. An incomplete call matches no candidate yet but still lists the set, which is exactly when the list helps most. Hover on the name shows the committed candidate’s signature (or, when the name is not being called, the last declaration’s) plus a (+N overloads) note, and go-to-definition jumps to the first declaration.

These rules shape the current corpus:

  • A genuinely parametric function gets a real generic. A higher-order helper whose result depends on the type of its argument, such as lapply, Reduce, print, invisible, setNames, or suppressWarnings, is written with <T> fn(...) binders and keeps a precise polymorphic scheme. Element-preserving vector functions (rev, sort, unique, head, tail, sample, rep, and the set operations) use <T> fn(x: T[]) -> T[], where the element parameter is limited to atomic types. That signature is usually the first candidate of an overload set whose list form threads list[T] through and whose Any fallback covers the rest.
  • A function that hands back the shape it was given declares each shape, narrowest first. R’s selection and reordering operations go through [, which preserves both the atomic type and the names, so the declarations say so. Filter takes T[], then list[named: T], then list[T], and each candidate returns its own shape; a single list[T] return for all three would make Filter(f, c(1, 2)) + 1 a type error on code R runs. lapply, rev, unique, head, and tail carry the same list[named: T] candidate ahead of their plain list one, so a named list in is a named list out. A fixed-shape input still coerces to a name-keyed list on the way in, though, so a field read off the result is T | NULL: the operation might drop a name, and the exact field types are not carried through.
  • A type-preserving reduction gets an overload set. Reductions whose result follows R’s coercion order rather than one input’s type (sum, min, max, range, pmin, pmax, cumsum, cummax, cummin, and abs with its integer and double split) declare one candidate per atomic family, plus the Any fallback.
  • A value-dependent result falls back to Any. When a function’s return type depends on an argument’s value or on how many arguments it gets, as with seq, is, and grep(value =), it returns Any rather than a falsely precise type. A call then yields Any and never a spurious type or arity error, and the name still resolves.

A project overrides or extends the shipped stubs with its own .Rtypes files under <project>/stubs/. The loader folds the project’s files over the shipped corpus in sorted path order, so a project declaration replaces the shipped declaration of the same name. That is how a project corrects a return type or adds a name the corpus lacks. A missing directory is ignored, and a malformed line is dropped, because one bad line must never block analysis.

A project stub file’s name declares its namespace. stubs/arrow.Rtypes declares the namespace arrow, so its declarations type bare reads and also validate qualified ones: with only read_parquet declared, arrow::read_parquet has the stub’s type, while arrow::write_parquet warns that it is not exported by arrow. This is how a project types a third-party package today, by hand. Exports are tracked per declaration, not per winning declaration, so overriding a shipped name’s type does not remove the name from its shipped namespace, and stats::sd stays valid under an sd override. Hover shows a name’s origin as the namespace of its winning declaration.

ry check reports every dropped override declaration as a stub error on its own line: a line that fails to parse, a declaration naming a type that does not resolve, and a @type line whose name is not an identifier. An unreadable override file is an IO failure. While a .Rtypes file is open, the editor shows the same problems as diagnostics.

Base R alone exports about 1,400 names, and the typed corpus deliberately covers only a curated subset. On its own, that would make every real export outside the subset warn as unresolved, because the checker could not tell recover, a real utils export, from a typo.

Export manifests close the gap. Every shipped namespace’s .Rtypes file is paired with a types/<namespace>.exports file listing, one per line, every name the namespace really exports. scripts/export-manifests.R generates them from a live R session and records the R version in the header, so refreshing them means rerunning the script against a newer R. The script refuses to overwrite a manifest recorded against a newer R than the running one, because regenerating on an older R would silently drop the names the newer version added, and every use of one would become a false unresolved finding.

A manifest does not need an .Rtypes file beside it. Some namespaces, such as tibble, tidyr, forcats, magrittr, glue, scales, knitr and tidyverse, have a manifest and no typed declarations at all, so every name from them is Unknown. What they contribute is a known export set. Attaching a package whose exports the checker cannot list turns off unresolved-name detection for the whole project, as the typing reference specifies, so without its manifest a single library(stringr) would silence every typo. Adding typed declarations for any of these packages later is purely additive: the manifest stays the export set, and the .Rtypes file supplies the types.

A manifest name is a known global that makes the weakest possible claim:

  • A bare or pkg::-qualified read of a manifest name always resolves, with neither an unresolved nor a not-exported warning. It has the type of its .Rtypes declaration if there is one, and Unknown otherwise. Precision is the typed corpus’s job; the manifest’s job is to stay quiet about names that really exist.
  • Completion offers manifest-only names without decoration, since there is no type to show. A non-syntactic name is skipped when completing a bare name and backtick-quoted after pkg::, since inserting it raw would change the syntax. Typo suggestions draw on manifest names too.
  • The unit suite pins the pairing in both directions: every value declaration in a shipped .Rtypes file must appear in its namespace’s manifest, and a stubbed non-export is a hard failure. That catches a declaration added to the wrong file, and names R has moved between namespaces, such as traceback and standardGeneric, which live in base rather than utils or methods. A conditional namespace may additionally override a base name, as data.table’s class-preserving merge does.

The manifests mirror the three ways R exposes a namespace:

  • Attached by default. base, stats, utils, graphics, grDevices, methods, and datasets are visible everywhere without qualification, so their names always resolve. datasets is special: its objects are lazy data rather than namespace exports, so its manifest is built from the search-path listing. The famous data frames are typed in datasets.Rtypes, which makes iris a data.frame rather than Unknown.
  • Shipped with R but not attached. tools, parallel, compiler, grid, splines, stats4, and tcltk validate their pkg:: reads in every project, so tools::file_ext(path) needs no library(tools), exactly as in R. A bare read resolves only once the project attaches or declares the package.
  • Conditional. data.table, dplyr, ggplot2, testthat, and every manifest-only CRAN namespace switch on their manifest and their stubs together, as described under conditional namespaces. While a package is inactive, its names warn whether they are qualified or not. A meta-package activates the packages it attaches: library(tidyverse) exports almost nothing itself, but it attaches dplyr, ggplot2, tibble, tidyr, readr, purrr, stringr, forcats, and lubridate, so those nine switch on with it. That is also how such a project gets dplyr’s and ggplot2’s typed declarations instead of a manifest’s Unknown. The membership is easy to check, because the generator script prints what a live library(tidyverse) attaches.

The manifests vendor R’s export lists, so analysis never needs an R installation. They are data, not types, and a manifest name costs nothing at check time until some code actually reads it.

Everything that can be written as a type scheme has one source of truth, the stubs, plus a small hardcoded kernel:

  • Value bindings: T and F become a monomorphic Scalar(Logical), and pi becomes a Scalar(Double).
  • Base functions: each becomes a type scheme, seeded into the template environment next to the built-in kernel.

The operators, c, and list are algorithms, not types. The operators need the numeric promotion lattice and the comparison families, c needs variadic atomic promotion that drops NULL, and list needs to synthesize records and tuples. The #: grammar can express none of that, so these stay built in. The one-source-of-truth goal holds for everything a type can describe, and the kernel is a deliberate exception, not an oversight.

A rest parameter is written ...: TYPE, as in fn(...: Any) -> character, and parameter and field names may contain an interior ., as in na.rm. Each gap below limits how precise the affected declarations can be:

  • A trailing dot in a parameter name, as in stop(call. =): parameter names allow only an interior dot.
  • An empty type for a function that never returns, such as stop and q: without one, a NULL return would poison the join in x <- if (ok) v else stop(...), so these stay Any.
  • A return that mirrors the argument’s shape, as in rev(opts)$timeout on a fixed-shape opts: a declaration cannot say “the same record back”, so selection and reordering return a name-keyed list[named: T], and a field read off it is T | NULL rather than the field’s own type.
  • A nullable result under a member-wise operator, as for names, dim, and nrow: a T | NULL return would be a false report on 1:nrow(df) and for (nm in names(x)) until flow narrowing or NULL-tolerant joins exist, so these return Any.

Type-preserving reductions declare overload sets, and element-preserving functions declare generic T[] signatures. A function serves a callback interface whenever it accepts every call shape the interface promises, so a callback-style stub can declare its real signature, spare optional formals included. What remains a scalar claim is a function whose result’s atomic type differs from its input’s while its shape follows the input, such as nchar or toupper.

Stubs are immutable, set-once inputs, the analogue of rust-analyzer’s Durability::HIGH: they are loaded, parsed, and interned once, and no ordinary edit invalidates them. There is one deliberate exception. Assembling the library also reads the package-metadata input, which switches the conditional namespaces on and off, so when a project starts or stops declaring or attaching such a package, the library is rebuilt. That is rare, and worth the full refresh.

The StubLibrary is a handful of flat, string-keyed tables, built once and folded over every source:

  • schemes maps a name to its ordered list of schemes, one per declaration (see overload sets).
  • nominals is the set of @type names.
  • masked maps each @masked name to the formals declared before its ....
  • pure holds each @pure name, and pure_by_namespace the same names per declaring namespace, for pkg::name calls.
  • exports_by_namespace maps a namespace to the names it declares.
  • declarations maps a name to the source index and range of its winning declaration. The declaring namespace is derived from the source index rather than stored, so the two can never disagree.
  • known_exports is the union of every manifest’s names.

Typing is not partitioned by namespace. The per-namespace export table answers pkg::name validation next to the flat scheme map, which is why an override that wins a name’s type never un-exports it from the namespace that declared it.

  • Every shipped .Rtypes file is folded into the one flat map in file order. A later source that redeclares a name replaces its whole entry, the same rule that governs project overrides, while repeated declarations within one source build the name’s overload set. The seven default-attached namespaces therefore join the base scope together, and a conditional namespace joins the fold only while it is active.
  • The flat map seeds the per-document inference template, so every stub name resolves as a bare global no matter which shipped file declared it. A name’s last scheme becomes its plain environment binding, because the corpus orders candidates from specific to general. A name with several schemes also registers its overload set for call-site selection.
  • pkg::name resolves against the same flat map, so a qualified read has exactly the type of the bare name. The per-namespace export table powers the warnings for an unknown namespace and for a name a namespace does not export, which the typing reference specifies under namespace access.

Some shipped namespaces describe packages R does not attach by default: data.table, dplyr, ggplot2, testthat and the benchmark packages come with types, and the manifest-only CRAN set comes without. Folding them in unconditionally would let fread, mutate, or str_to_upper resolve in a project that never uses them, stealing a typo warning. So a conditional namespace joins the library only when the project uses the package, and any one of these signals is enough:

  • A DESCRIPTION dependency field (Depends, Imports, Suggests, or Enhances) names it, or a NAMESPACE import or importFrom names it as a source.
  • A project file attaches or loads it with library(), require(), requireNamespace(), or loadNamespace(), with a literal name or string as the package argument, anywhere in the file. This is the only signal a script workspace has, since only packages carry metadata files.
  • The project ships its own stubs/<pkg>.Rtypes override for the namespace. Writing one is the clearest possible statement that the project uses the package, and the override then folds over the shipped declarations as usual.
  • A meta-package that attaches it is active: library(tidyverse) activates the nine packages it attaches, because attaching is how R makes their names reachable.

testthat is in the set because a package’s tests/ directory is often a third of its source, and without a known export set, attaching testthat would silence typos in the package’s own functions. With the stubs, expect_eqaul is reported, along with a suggestion. An expectation’s object parameter is Any, because an expectation compares whatever the code under test produced, and each expect_* returns its object, which is the value testthat passes through invisibly.

While a namespace is inactive, it simply is not there: its names warn as unresolved, a pkg:: read behaves like a read from any package without stubs, and its @type nominals are unknown type names. The hosts keep the attach facts current for each synced file. The language server rescans only the changed document and fills in the rest of the workspace during idle priming, so switching a namespace on never costs a whole-project sweep on a keystroke.

ry check and the language server both validate a package’s NAMESPACE file against the loaded stubs. An importFrom(pkg, name) whose namespace the corpus knows (shipped or project) but which does not export name is an error at the import:

`medain` is not exported by `stats`, so this package will not load.

R refuses to load a package with such an import, so the code cannot run at all; it is the same rule as an export() of a name the package never defines. A namespace without stubs is not checked, because stubs are the only export source the checker has. Resolution itself is unchanged, and a stubbed name resolves bare with or without an import.

The check reads a string-keyed export table built once from the loaded corpus, not the engine’s interner, which is what lets an open NAMESPACE buffer be validated live in isolation.

The StubSources input is set once, and the library derived from it changes only when a conditional namespace switches on or off, so an ordinary edit never invalidates a query that reads the stubs, and a document pays inference time only for the stub names it uses.

A package binding that shadows a stub name is an ordinary structural edit. It changes which definition wins that name in the package symbol index, and that change flows through the per-symbol interface to exactly the files that reference the name, the same path as any definition appearing or disappearing. A stub value and an unrelated user type that share a name, such as the value T and #: @type T, live in the value and type namespaces respectively, and never interact.

Scope: model only what is statically reasonable

Section titled “Scope: model only what is statically reasonable”

The corpus models:

  • fixed-arity signatures;
  • scalar constants;
  • common vectorized atomic operations with a stable result shape;
  • prenex rank-1 generics;
  • nominal type and class declarations.

It deliberately does not model the following. For these, declare the part of the signature that is statically stable and stop, or use Any:

  • non-standard evaluation and data masking, such as subset, with, and formulas;
  • tricks that forward ...;
  • the dispatch dynamics of S3, S4, R5, and R6, such as UseMethod and NextMethod;
  • do.call, match.arg, match.call, sys.call, and Recall;
  • reflective environment access, such as environment, assign, get, and eval;
  • partial argument matching;
  • replacement functions, such as `names<-`.

The rule of thumb: if a function’s return type is not a static function of its argument types, but depends on a runtime value or class, leave the declaration out or give it Any.

The corpus optimizes for two things, in this order: no false errors on idiomatic calls, and then the most precise return type that is still sound. The compromises that keeps forcing are named once in the header of base.Rtypes and referred to by name in individual entries:

  • Any-param: a parameter is Any even though R documents a type for it, because R itself coerces arguments and nchar(42) is legal, so a declared type would reject calls R accepts. The checker’s own coercions reduce how often this is needed: an integer widens to double at a parameter, a whole-number double literal counts as an integer, and a scalar coerces into a vector position. The T[] generics restore the rest of the precision.
  • scalar-claim: an elementwise function declares its scalar result (character rather than character[]). A scalar claim coerces into every vector position and so can never cause a false positive downstream, whereas a vector claim would break if (grepl(...)).
  • type-preserving: the result’s type follows the input’s, as for sum, sort, and rev. Element-preserving vector functions declare <T> fn(x: T[]) -> T[], and reductions whose result follows R’s coercion order declare one overload candidate per atomic family. Every set ends with an Any fallback, which is also what a call selects while an argument’s type is still undetermined; because a fallback taking Any binds nothing, it never narrows the caller.
  • NULL-hybrid: the result is T or NULL depending on the runtime value, as for names, dim, and nrow. These return Any, for the reason the gaps table above gives.
  • named-formals: declare the named formals whose types are worth checking, such as paste’s sep and collapse or read.csv’s header and sep. An undeclared named argument to a variadic function is not an error: the rest parameter absorbs it and checks it against the rest parameter’s element type, just as R would accept it. So an open space of named arguments works with ...: Any, as for read.csv’s pass-through to read.table and lm’s fitting controls. Declaring a formal buys a typed check; leaving it out buys pass-through.

Here is a fragment of the base stubs:

T : logical
F : logical
pi : double
length : fn(x: Any) -> integer
nchar : fn(x: Any, [type]: character, [allowNA]: logical, [keepNA]: logical) -> integer
seq_len : fn(length.out: Any) -> integer[]

Each line produces one scheme, which hover renders back in the same notation.

nchar’s subject and seq_len’s count are Any-params (see the compromise vocabulary), so nchar(42) and seq_len(10) check clean while the return types stay precise.

This R type-checks against those stubs plus the built-in kernel:

n <- length(c(1L, 2L, 3L)) # integer, from the stub scheme
half <- pi / 2 # double, from the operator kernel
flag <- T # logical, from the stub value binding

paste’s variadic arguments and its sep and collapse named formals can be declared directly, just like seq_len’s dotted length.out:

paste : fn(...: Any, [sep]: character, [collapse]: character | NULL, [recycle0]: logical) -> character

Where the rest parameter sits among the named ones is part of the signature, because it decides which parameters a positional argument can still fill.

  • Third-party packages beyond the shipped set. Any other CRAN package’s names are Unknown, with an unknown-namespace warning, unless the project writes its own stubs/<pkg>.Rtypes. The intended answer is generating stubs by introspecting the installed library, because which packages and versions are installed is a property of the project, not of ry.
  • Keying the corpus to an R version. The shipped corpus describes one snapshot of base R rather than whichever version a project runs.
  • A name-to-scheme table per namespace, instead of one flat fold. Today two shipped packages cannot declare the same name with different types.
  • library(pkg) attaching a namespace on demand for a package outside the shipped set.
  • Gating bare third-party names on NAMESPACE imports. That would be R-correct, and whether to do it is a deliberate strictness decision; today’s flat fold is intentionally permissive.