Type system
Two principles explain most of the rules on this page. First, the checker prefers skipping a check
to giving a wrong answer: a construct it cannot describe becomes Unknown, which is compatible with everything,
so a gap means a skipped check rather than a false error, and strict mode shows where
the gaps are. Second, every rule must be cheap enough to check on every keystroke, which is why
inference is Hindley–Milner, why your own functions cannot be overloaded, and why there is no
subtyping between nominal types. The tour introduces the same ideas through examples.
Typing comment syntax
Section titled “Typing comment syntax”Annotations live in #: comments, directly above the binding or expression they describe.
Consecutive #: lines with no blank line between them form one annotation block.
Each annotation takes one of these forms:
#: TYPEis a checked annotation.#: @trust TYPEis a trusted coercion.#: @if-unknown TYPEis an unknown-only coercion.#: @new NOMINAL_TYPEis a nominal introduction.
A block is one of three kinds, which cannot be mixed: a single line in one of those forms, an
expanded function annotation written as @param and @return
lines, or a definition block of @type and @alias lines.
#: integervalue <- 1L#: fn(count: integer) -> integerdouble_count <- function(count) count + countA block can attach at any statement depth, not only at the top level. Inside a function body it annotates the assignment or the block-final expression that follows it, which is how a function that builds a value of a named type is written:
#: @type Person {list{name: character}}
#: fn(name: character) -> Personmake_person <- function(name) { #: @new Person list(name = name)}Attachment
Section titled “Attachment”A block attaches to the expression that starts on the line directly after it. When a block needs a target and has none, it is an error and the annotation does not apply. That happens when:
- a blank line separates the block from the expression;
- a plain
#comment separates them, or no expression follows at all; - the block has no content beyond the
#:marker; - the block sits inside a call’s argument list, for example beside a lambda passed to
lapply, because an argument is not a statement. Give the value its own binding and annotate that instead.
A braceless function body, a braceless if branch, and a parenthesised expression are valid
targets.
A definition block, one that contains only @type and @alias lines, attaches to nothing, and
neither does a @strict toggle, so the adjacency rules do not apply to them.
Refused blocks
Section titled “Refused blocks”A block is refused as a whole when it mixes forms, orders its directives wrongly, declares a
duplicate or unknown type parameter, or gives @new a payload that is not nominal. A refused block
reports its error and carries no typing payload, so a broken annotation does not cause follow-on
findings. Two known exceptions still do: a [] vector whose element type is not atomic or is not
declared (Rec[]), and a bare optional positional parameter (fn(integer, [character])). A block
the annotation grammar could not even read is refused silently, because the parse error has already
said what was wrong.
Atomic names
Section titled “Atomic names”Types use R’s own names: logical, integer, double, complex, character, raw, and NULL.
The names from other languages, such as bool, int, float, and string, are not accepted.
Reserved constants
Section titled “Reserved constants”R’s reserved constants have fixed scalar types:
TRUEandFALSEarelogical.NAislogical, whileNA_integer_,NA_real_,NA_complex_, andNA_character_areinteger,double,complex, andcharacter.InfandNaNaredouble.- An imaginary literal such as
1iiscomplex. NULLisNULL.
Vector shapes
Section titled “Vector shapes”Every atomic vector type comes in three shapes, written with a suffix on the atomic name:
1L # integer: scalar-like, a vector of length onec(1L, 2L) # integer[]: array-like, a vector of unknown lengthc(a = 1L, b = 2L) # integer[named]: map-like, keyed by namesVector coercions
Section titled “Vector coercions”- A scalar-like
Tcoerces to an array-likeT[]. - A map-like
T[named]coerces to an array-likeT[]. - An atomic type coerces up R’s promotion order,
logical<integer<double<complex, in every shape. That coverslogicaltointeger,integer[]todouble[],double[named]tocomplex[named], and compositions such as a scalarintegertodouble[]. The reverse never holds.characterandraware not part of the order, because R reachescharacteronly through an explicit conversion. - No other coercion between vector types is allowed unless a rule elsewhere on this page states it explicitly.
List shapes
Section titled “List shapes”R has one list type, but programs use it in four different roles, and the type system gives each role its own type, because each role allows different checks:
- tuple-like,
list{T1, T2, ...}: no names, a fixed number of positions, each with its own type. - list-like,
list[T]: no names, any length, one element type. - record-like,
list{name: T, ...}: named fields, a fixed set of them, each with its own type. - dict-like,
list[named: T]: names, any length, one element type.
The tuple-like and record-like shapes have a fixed shape: their positions or field names are part of the type. The list-like and dict-like shapes are homogeneous collections: every element has the same type, and no particular position or name is part of the type.
A list(...) expression infers a fixed shape whenever its elements carry enough information:
- When all elements are unnamed, it is tuple-like.
- When all elements are named, it is record-like.
- When named and unnamed elements are mixed, the names are dropped and it is list-like, so
list(1L, bar = "foo")islist[integer | character].
A fixed shape wins wherever the elements allow one. list(1L, 2L, 3L) is
list{integer, integer, integer}, not list[integer], and list(foo = 1L, bar = 2L) is
list{foo: integer, bar: integer}, not list[named: integer]. List-like and dict-like types mostly
come from annotations, and from coercing a fixed-shape list.
List coercions
Section titled “List coercions”- Any list coerces to a list-like
list[T]when every element is compatible withT. - A record-like or dict-like list coerces to a dict-like
list[named: T]when every value is compatible withT. - The reverse is never allowed: a
list[T]does not coerce back into a tuple-like, record-like, or dict-like list, and alist[named: T]does not coerce back into a record-like one.
Tuple-like lists
Section titled “Tuple-like lists”A list(...) expression whose elements are all unnamed is tuple-like, even when every element has
the same type:
list()islist{}.list(1L, 2L, 3L)islist{integer, integer, integer}.list(1L, "foo")islist{integer, character}.
Record-like lists
Section titled “Record-like lists”A list(...) expression whose elements are all named is record-like when the names are known
statically, so list(foo = 1L, bar = "foo") is list{foo: integer, bar: character}.
Two records are compatible when they declare the same field names and each field’s type is
compatible with the field of the same name on the other side. Fields pair by name, so their order
does not matter: list(label = "a", id = 1L) satisfies list{id: integer, label: character}.
R lets a list name be any string. A field name that is not a syntactic R name is written, and
rendered, in backticks, so list(\max size` = 10L) has the type `` list{max size`: integer} “.
Reporting a record that does not fit
Section titled “Reporting a record that does not fit”When a record is rejected, the finding names the one field that failed rather than printing both
whole types. That field is one of three things: a field both sides declare whose types do not fit, a
field the expected type declares that the value lacks, or a field the value has that the expected
type does not declare. For a nested record, the finding names the path, outermost field first, as
in retry.count.
List-like lists
Section titled “List-like lists”A list-like list[T] is a list whose elements all share the type T, with no fixed
positions and no requirement that element names be known. Such types usually come from an
annotation, or from coercing a fixed-shape list whose values are all compatible with T.
A fixed-shape list coerces into list[T] with T being the union of its element types, so
lapply(list(1L, "a"), f) passes integer | character. Coercion into list[named: T] works the
same way. Where T is already concrete, every element must fit it.
Dict-like lists
Section titled “Dict-like lists”A dict-like list[named: T] is a name-keyed collection whose values all share the type T,
without requiring the set of names to be known. Such types usually come from an annotation, or from
coercing a list whose element names are not statically available.
The empty list
Section titled “The empty list”list() is the empty tuple list{}. Since it has no element whose type or name could conflict, it
is compatible with every element-typed list shape, list[T] and list[named: T] alike. That makes
function(options = list()) a usable default for a list[named: T] parameter. A record type with
required fields still rejects it, because those fields really are missing.
The literal NULL has the type NULL, which is the unit type of this type system and is
incompatible with every other type. An empty block is also NULL.
Any and Unknown
Section titled “Any and Unknown”Any and Unknown are both compatible with every type, in both directions. They differ in where
they come from, and therefore in what they mean.
Any is an explicit opt-out from type checking. It comes either from an annotation you wrote or
from a standard-library declaration. The shipped declarations use it where any precise type would
reject calls R accepts, so seeing Any in a hover is not by itself a sign of a problem.
Unknown
Section titled “Unknown”Unknown means the checker could not work out a more specific type, because of an unsupported or
partly supported construct, an unresolved name, or simply too little information. It is not an
opt-out: it records a gap in the checker’s knowledge.
Because Unknown is compatible with everything, a gap means a check is skipped rather than answered
wrongly, and a value the checker could not type flows into a double parameter without complaint.
The difference in intent from Any changes behavior in exactly one place:
@if-unknown supplies a type where one is missing, so it applies to an
Unknown and is refused on an Any, which already says not to check the value.
Strict mode reports the sites where a type could not be determined, not every type
that happens to be Unknown. A declaration whose return type is Unknown produces no finding at its
call sites, and an Unknown nested inside a larger type is not reported either.
Type parameters, aliases, and nominal types
Section titled “Type parameters, aliases, and nominal types”A type expression can bind type parameters with a leading binder, written <T> TYPE or
<T, U> TYPE:
<T> list[T]<T> list{ value: T }<T, U> fn(T) -> U<T> fn(T) -> T | NULL
A binder name may carry a constraint, written NAME: CONSTRAINT, as in
<T: numeric> fn(values: T) -> T. There are three constraints:
numericadmitsintegeranddouble, scalar or vector, and any class that declares an arithmetic operator method.atomicadmits one of the six atomic scalar types. Using a parameter as a vector element,T[], imposes the same bound.scalar numericadmits a scalarintegerordouble, which is both bounds at once. It is what an endpoint of:infers, sofunction(n) 1:nis<T: scalar numeric> fn(n: T) -> double[].
Any other constraint name is an annotation error, and the error lists the three that exist. An argument whose type violates a constraint is a type error at the call.
A constraint limits what a caller may instantiate T with, and the annotated body may rely on it.
Under <T: numeric> fn(x: T) -> T the body may use x as a number, because every admissible
instantiation is numeric. A body under a plain <T> that does arithmetic is a type error, and
atomic does not imply numeric.
Binders are rank-1: a binder is allowed only at the outermost level of a type expression, never
nested inside another type. A directive’s {...} payload does not count as the outermost level, so
the expanded form declares its type parameters with @forall, and a named type declares them on its
name, as in @type Pair<T>. All three of these are refused:
fn(f: <T> fn(T) -> T) -> integerlist{ value: <T> list[T] }@param f {<T> fn(T) -> T}
A refused binder is reported once. The type is then read as though the binder had not been written, and the block carries no typing payload.
Named types are declared with @type and @alias, and either may take parameters, as in
NAME<T, U>:
#: @type NAME {TYPE}declares a nominal type whose representation isTYPE.#: @alias NAME {TYPE}declares a structural alias forTYPE.
The two forms share one namespace, so reusing a name under either form is an error. A definition block is allowed only at the top level of a file. Definitions are project-global, so a reference need not come after the declaration, whether in the same block or the same file; type names has the scoping rules.
Type parameters and generic application
Section titled “Type parameters and generic application”A generic application must match its declaration’s arity, checked against the project’s vocabulary:
- Applying the wrong number of type arguments is an error at the applied name, as
Box<integer, double>is for a one-parameterBox<T>. - Applying type arguments to a declaration that is not generic, such as
Meters<integer>, is an error. - A bare reference to a generic, such as
Boxwithout arguments, is an error everywhere except after@new, where an unapplied generic infers its arguments through the representation check. - A wrongly applied name then compares like
Unknown, so the one arity error never cascades into mismatches between values.
Named generics, whether aliases or nominal types, are applied with angle brackets, as in
Box<integer> and Pair<integer, character>. For Pair<T, U>, Pair<integer, character> is
valid, while Pair<integer> and Pair<integer, character, double> are errors. @new uses the same
syntax to introduce a value of a generic nominal type: #: @new Person<integer> is valid when
Person<T> is declared with @type, and so is the unapplied #: @new Person.
In @type NAME<T, U, ...> {TYPE} and @alias NAME<T, U, ...> {TYPE}, the declared type parameters
are in scope only within TYPE, where they shadow any project-global type of the same name. A type
parameter is not itself a generic, so applying type arguments to one, as in Wrap<integer> where
Wrap is a parameter, is an annotation error rather than a reference to the shadowed global.
A type parameter may appear inside a structural type, a function type, and the vector suffix forms:
list[T], list{ value: T }, fn(T) -> T, T | NULL, T[], and T[named].
Using a type parameter as a vector element restricts it. A T in T[] carries the atomic-element
bound, so it can only be instantiated with one of the six atomic types: logical, integer,
double, complex, character, and raw. This is what makes element-preserving signatures
possible. With keep : <T> fn(x: T[]) -> T[], keep(c("b", "a")) is character[] and
keep(c(1L)) is integer[], while a list argument cannot bind T at all, because a list is not an
atomic element type. Several rules follow from the bound:
- A scalar argument coerces into a generic vector parameter and binds the element, so
<T> fn(x: T[])called with2.5bindsT := double. [[on a generic vectorT[]extractsT.- An arithmetic operator on a
T[]operand also requires the element to be numeric. The variable then holds both bounds, so it is a scalarintegerordouble. The result keeps the element, sokeep(x) + 1Lis stillT[], unless adoubleoperand promotes the result todouble[]. - A comparison on a
T[]operand yieldslogical[], and a numeric partner requires the element to be numeric.
A bound that can no longer be satisfied is a type error at the expression that imposed it, whether
that means binding an element variable to a type that is not atomic or requiring a character
element to be numeric.
Writing X[] is an error when X is neither an atomic type nor a type parameter, because a vector
holds only atomic elements. Records, functions, and nominal types are all such an X, and list[X]
is how to write a list of them. An alias expands first, so Id[] with @alias Id {integer} is fine,
while an alias of a record is refused.
Type aliases
Section titled “Type aliases”An alias is purely structural: writing its name is the same as writing the type it stands for. It creates no new identity and is compatible with whatever its underlying type is. An alias may appear anywhere a type may, including inside a larger type. Using an alias that expands in a cycle is an error, reported where it is used; a cycle no annotation uses is not reported.
#: @alias PersonShape {list{ name: character, age: double }}
#: PersonShapevalue <- list(name = "bob", age = 20)A generic alias may use its parameters anywhere inside the underlying type:
#: @alias Box<T> {list{ value: T }}
#: Box<integer>value <- list(value = 1L)Nominal types
Section titled “Nominal types”A nominal type creates a fresh type identity, even when another nominal type has exactly the same representation. A nominal type name may appear anywhere a type expression may, and a generic nominal may use its type parameters anywhere inside its representation.
- A nominal type is compatible with itself.
- Two different nominal types are not compatible with each other, even when their representations are identical.
- A structural value is not compatible with a nominal type unless
@newintroduces it. - A value of a nominal type is compatible with its representation type, so it can be passed to
any function written for the representation, while
@newstays the only place the type’s invariant is established. - When an operator, an indexing form, or a loop needs a structural shape, a nominal value projects to its representation type. The result of the projection is structural, not nominal.
Person and Pet below are distinct and incompatible, even though their representations are
identical. A nominal value satisfies its representation type, so the annotation on shape is valid.
A structural value does not satisfy the nominal type, so the final call is an error:
#: @type Person {list{ name: character, age: double }}#: @type Pet {list{ name: character, age: double }}
#: @new Personperson <- list(name = "bob", age = 20)
#: list{ name: character, age: double }shape <- person
#: fn(value: Person) -> characterget_name <- function(value) value$name
get_name(list(name = "bob", age = 20))Projection is what makes field access and arithmetic work on a nominal value, and its result loses the nominal identity:
#: @type Person {list{name: character}}
#: @new Personperson <- list(name = "bob")
person$nameperson$name is character, because $ sees the representation of Person.
#: @type Meters {double}
#: @new Metersheight <- 1.8
height + heightheight + height is double: arithmetic projects Meters to double, and the result does not keep
the nominal identity.
A generic nominal takes its parameters on the declared name:
#: @type Person<T> {list{ value: T }}
#: @new Person<integer>person <- list(value = 1L)
#: list{ value: integer }shape <- personOpaque nominal types
Section titled “Opaque nominal types”An opaque nominal type has no representation to project. The standard-library stubs declare
data.frame, factor, connection, Date, and other types the grammar cannot describe
structurally as a bare @type NAME (see stubs). Four rules apply to them:
$,[, and[[are accepted, and giveUnknownrather than an error. The R objects behind these classes commonly support access that depends on values, such asdf$amountanddf[rows, ], and refusing it would reject ordinary R.- The access is not checked any further: there is no field-existence check, no index-count check,
and no index-type check, so
df[i, j]anddf[rows, ]both pass. - Every such access is an unsupported construct under strict mode.
- Arithmetic and every other structural requirement on an opaque nominal remain type errors, unless the class declares the matching operator method. The nominal identity itself checks like any other nominal type.
Type-argument variance
Section titled “Type-argument variance”Two applications of the same generic nominal type, such as Box<integer> and Box<integer | NULL>,
are compared one type argument at a time. The direction in which each argument is checked depends on
where its type parameter occurs in the representation, so each parameter’s variance follows from
its occurrences:
- Covariant positions keep the checking direction. They are a function’s return, a direct
occurrence, and an element of a container or structural type (a
listitem, alist{...}field, a tuple item, a vector element, or a union member). SoBox<integer>is accepted whereBox<integer | NULL>is expected, because a narrower argument satisfies a wider one. - A function parameter position is contravariant and flips the direction. Given
@type Handler<T> {fn(value: T) -> NULL}, aHandler<integer | NULL>is accepted where aHandler<integer>is expected, but not the other way around, because then aNULLcould reach a function that only acceptsinteger. - A parameter that occurs in both kinds of position is invariant, and its arguments must match
exactly. Given
@type Cell<T> {list{ get: T, set: fn(value: T) -> NULL }},Cell<integer>andCell<integer | NULL>are incompatible in both directions. - A parameter that does not occur at all constrains nothing, and accepts any argument.
A type parameter inside a nested generic application, such as the T of Sink<T> within
@type Outer<T> {Sink<T>}, is invariant: the inner type’s own variance does not compose with the
outer direction. When a generic nominal has no visible definition, every argument is checked
invariantly, which errs toward rejecting (requiring an exact match) rather than toward accepting an
unsound widening.
R’s lists and vectors are mutable, yet their element positions are still treated as covariant. A
sound mutable container would have to be invariant, which would break structural coercions such as
scalar to vector and T into T | NULL.
Union types
Section titled “Union types”A union type A | B | ... describes a value that has one of the member types. It may have any
number of members, and any type may be a member; T | NULL is the nullable form of T. A union can
be written anywhere a type can (a variable annotation, a parameter, a return, a nested function
type, a list annotation), and it describes the shapes a value can take without merging or coercing
its members:
integer | charactercharacter[] | NULLfn(count: integer | NULL) -> character | logical | NULL(fn() -> integer) | NULL, a function returninginteger, orNULL
That last example shows why a type may be parenthesized for grouping, where (TYPE) means exactly
TYPE. In fn() -> integer | NULL, the -> extends over the whole union, so an optional callback
has to be written (fn() -> integer) | NULL, which is also how it renders. A <T> binder may not
appear inside parentheses.
Union normalization
Section titled “Union normalization”Unions are kept in one normal form, so equivalent spellings mean the same type and render the same way:
- A member that is itself a union is flattened, so
(A | B) | CbecomesA | B | C. - Repeated members collapse, keeping the first, so
integer | character | integerbecomesinteger | character. - Member order does not affect meaning. Rendering keeps first-occurrence order, except that
NULLalways renders last. - A union whose members collapse to one type is that type, so
integer | integerisintegerandNULL | NULLisNULL. - A union with an
Anymember isAny, because every value already satisfiesAny. - Otherwise, a union with an
Unknownmember isUnknown.
The checker’s own unions are normalized too, so a rendered union is always flat, free of duplicates, and at least two members long.
Union compatibility
Section titled “Union compatibility”A value fits an expected union when it fits any one member, with the usual coercions applied per
member. So integer fits integer | character | NULL, and NULL fits any union that contains
NULL.
A union value has to be acceptable in every shape it can take, so it fits an expected type only
when every one of its members does. That means a union fits any wider union (integer | NULL fits
integer | character | NULL), but not a single member type: integer | character does not fit
integer, and T | NULL does not fit plain T.
Members are tried in order, and a failed attempt leaks no bindings into the next one.
An unannotated value checked against an expected union binds to the whole union at its first use.
Uses commit in program order, so a later use that needs a different type is reported at that later
site. When two union-typed contracts share only some members, such as integer | character at one
call and logical | character at the next, the checker does not compute their intersection; annotate
the value with the member type you intend.
Function types
Section titled “Function types”A function can be annotated in one of two styles, but not both at once:
- the expanded style, with an optional
@forall, then@paramlines, then@returnor@returns; - the compact style, a single
fn(...)type with an optional-> RETURN_TYPE.
Either way, consecutive #: lines form one annotation block for the function, not a series of
independent annotations.
Expanded function annotations
Section titled “Expanded function annotations”The expanded form puts one directive on each line: @forall to declare type parameters,
@param name {TYPE} for each parameter (or @param [name] {TYPE} for an optional one), and
@return {TYPE} or @returns {TYPE} for the result. A constrained binder is written
@forall T: numeric, with the same names and meaning as the compact <T: numeric>.
#: @param count {integer}#: @param [label] {character | NULL}#: @return {integer}double_count <- function(count, label = NULL) { count + count }#: @forall T#: @param condition {logical}#: @param value {T}#: @return {T | NULL}then_some <- function(condition, value) { if (condition) value}- Directives come in order: every
@forallbefore any@param, and every@parambefore@returnor@returns. - Repeated
@foralllines accumulate in source order, and a duplicate type parameter name is an error. - At most one
@returnor@returnsmay appear. Without one, the return type is elided.
Compact function annotations
Section titled “Compact function annotations”A compact annotation is a single function type, in one of these forms:
fn(name: TYPE) -> RETURN_TYPEfn(TYPE) -> RETURN_TYPEfn(name: TYPE, [optional_name]: TYPE) -> RETURN_TYPE<T> fn(name: TYPE) -> RETURN_TYPE<T, U, ...> fn(TYPE) -> RETURN_TYPE
A leading <...> binder introduces rank-1 type parameters for the whole function type. The binder
always comes first: fn<T>(...) is not the syntax. An optional parameter must be named, as in
[name]: TYPE; a bare optional positional form such as fn(integer, [character]) is not supported.
Omitting the return type elides it.
A function may declare one rest parameter to accept a variable number of arguments. It is written
...: TYPE, and fn(...) is shorthand for ...: Any. It has no name, because rest arguments are
matched by position, so ...items: TYPE does not match the function’s ... and is reported, twice
at the same range: as a signature mismatch and as a parameter the function does not define. It can
come after the fixed
parameters, as in fn(prefix: TYPE, ...: TYPE) -> RETURN_TYPE, or before named ones, as in
fn(...: TYPE, [option]: TYPE) -> RETURN_TYPE:
#: fn(...: character) -> characterjoin <- function(...) paste0(...)
#: fn(x: character, ...: character) -> characterwrap <- function(x, ...) paste0(x, ": ", paste(...))The position of the rest parameter is part of the signature. It must match the position of ... in
the function’s formals, counted as the number of parameters declared before it, and it decides which
parameters a positional argument can fill (see function calls and
function type compatibility).
Annotations and the formal list
Section titled “Annotations and the formal list”An annotation declares the types of a definition’s parameters; it does not declare the parameter
list. R matches a call’s arguments against the formals in the function(...) header, so those
formals are the call interface: their names, their order, their defaults, and where ... sits. An
annotation cannot add, remove, or reorder them. A parameter the annotation does not mention keeps
its inferred type, which makes annotating just one of several parameters a supported partial form.
When the declared shape disagrees with the definition, the definition wins at every call site, and the disagreement is reported once, at the definition. A call is never blamed for a mistake in an annotation. This is the same rule that refused blocks follow, extended to an annotation that parses cleanly but has the wrong shape. The possible disagreements are:
- a declared parameter name that is not one of the formals, meaning the annotation describes a parameter the function does not have;
- more declared parameter types than there are formals left to receive them;
- a declared optional
[name]over a formal with no default, because declaring a parameter optional tells callers they may omit it, so the formal must have a default; - a parameter declared as required over a formal that has a default. R lets callers omit it, so the
annotation must say
[name]; - a rest parameter at a different position in the annotation than in the formals. The rest parameter must also exist on both sides or on neither, so a fixed annotation on a variadic function and a variadic annotation on a fixed function are both rejected.
The first case, and a required declaration over a formal with a default, name the parameter. The others are reported as a mismatch of the whole signature. In every case, the body is still checked under whatever parameter types the annotation does pin down, so hover and navigation keep their facts.
Elided return types
Section titled “Elided return types”Both styles allow the return type to be left out: an expanded block without @return or
@returns, or a compact fn(...) without -> RETURN_TYPE. An elided return is not the same as a
written NULL, and what it means depends on whether there is a function body to infer from.
When there is a body, the return type is inferred from it, exactly as if there were no
annotation. That is the case for a checked annotation on a function definition, meaning an
annotation attached to a function(...) literal whose body is checked against it. Annotating only
the parameters is the usual partial form, and it must not silently pin the return type, so
@param u {integer} on add_one <- function(u) u + 1L infers fn(u: integer) -> integer. Writing
the return as Unknown has the same effect, because Unknown says nothing is known and so never
overrides what the body shows. (Any is the annotation that turns checking off for a value.)
When there is no body, an elided return means NULL in a nested function type, such as a
callback parameter written @param cb {fn(integer)}, which matches R functions called for their
side effects. In a trusted coercion, and in an annotation on a value that is not
a function literal, such as #: fn(integer) on g <- f, the elided return is Unknown, so a call’s
result goes unchecked even when f’s return type is known. This is a known gap.
A function that really does return NULL can always say so, with @returns {NULL} or -> NULL.
The explicit form is enforced, so a body that returns anything other than NULL is then a type
error.
#: fn(count: integer) -> integerdouble_count <- function(count) count + count#: fn(count: integer, [label]: character | NULL) -> integerdouble_count <- function(count, label = NULL) count + count#: fn(count: integer)log_count <- function(count) { }#: <T> fn(value: T) -> Tidentity <- function(value) value#: <T> fn(condition: logical, value: T) -> T | NULLthen_some <- function(condition, value) { if (condition) value}Inferred function types
Section titled “Inferred function types”An unannotated function(...) expression gets its type from the definition itself:
- Every parameter appears as a named parameter under its name in the definition, because R matches parameters both by name and by position.
- A parameter with a default is optional at call sites, and so is one the body tests with
missing(). - A
...formal becomes a rest parameter with element typeAny, at the position it holds among the formals, sofunction(x, ...) …isfn(x: T, ...: Any) -> …. The values that reach...are not tracked into the body, so forwarding...to another call givesUnknown. - Parameter and return types are inferred, and a parameter nothing constrains generalizes when the
function is bound to a name, so
function(x) xis<T> fn(x: T) -> T. - A requirement inference could not discharge survives into the exported type: a parameter used as a
number exports as
<T: numeric>, so calls from other files keep being checked.
function(count, label = NULL) count may therefore be called as f(1L), f(count = 1L), or
f(1L, "x").
Defaults
Section titled “Defaults”Defaults are type-checked, and they interact with the parameter’s type in four ways:
- An error inside a default is reported, and the default of an annotated parameter must be compatible with the declared type.
- A
NULLdefault is checked like any other.function(title = NULL)is R’s usual way to write an optional argument, but it does not make the parameter optional inside the body: when the caller omits it,titleisNULLthere, so declaring itcharacteris a promise the function does not keep. Declarecharacter | NULLand narrow withif (is.null(title)). Marking the parameter[title]relaxes only the call. - An unannotated parameter takes its type from its uses, not from its default, so
function(x = 1) xis<T> fn([x]: T) -> T, and passing a character is not a finding. - A call that omits the argument gets the default’s type, because that is the value R puts in the
frame: with
f <- function(x = 1) x,f()is adoubleandf("a")is acharacter. A default is therefore checked against an instantiation of the declared type rather than against the binder, so#: <T> fn([x]: T) -> Toverfunction(x = 1) xis accepted. A concrete declared type is unaffected:fn(title: character)still refuses aNULLdefault.
Named and positional parameters
Section titled “Named and positional parameters”Parameter names in a function type are part of the call interface. A named parameter may be passed
as a named argument, while an unnamed parameter is positional only. So fn(count: integer) -> integer
allows a call with count = 1L, while fn(integer) -> integer makes a named argument a type error,
but only when no definition stands behind the type. When the annotation sits on a function(count)
definition, the formals supply the name, and f(count = 1L) stays legal.
An optional parameter follows the same rule, and must be named, as in
fn(count: integer, [label]: character) -> integer.
Parameter, field, and type names may contain ., matching R’s convention for names such as
na.rm and length.out: fn(x: double, na.rm: logical) -> double, list{na.rm: logical}, and
@type My.Type {integer} are all valid.
Function type compatibility
Section titled “Function type compatibility”A function value is compatible with an expected function type when its parameters accept every call the expected type may make, and its return type satisfies the expected return type.
Parameter names are part of the interface, because R matches arguments against the definition’s formal names:
- A named parameter pairs by name, so
fn(a: integer, b: character)accepts a function defined asfunction(b, a). - An unnamed parameter type pairs with the remaining parameters from left to right, so
fn(count: integer) -> NULLandfn(integer) -> NULLare compatible with each other. - An annotation may not rename a parameter.
fn(count: integer) -> integeroverfunction(n) nis an error, because it would promise callers a name the function does not accept.
Arity is a range, not a fixed count. The function may declare more parameters than the interface passes, as long as the extra ones have defaults. It may not require more than the interface supplies, and it may not refuse an argument the interface may send. An optional parameter in the expected type promises callers they can omit it, so the function’s formal must have a default.
Parameters are contravariant and the return type is covariant. Each expected parameter type must be compatible with the function’s parameter type, so the function accepts every argument the interface may pass, and the function’s return type must be compatible with the expected one:
fn(integer | NULL) -> integeris accepted wherefn(integer) -> integeris expected.fn(integer) -> integeris rejected wherefn(integer | NULL) -> integeris expected, because the interface may passNULL.fn(a: integer, [b]: integer) -> integeris accepted wherefn(integer) -> integeris expected, becausebhas a default. This is what lets a standard-library function serve as a callback:lapply(list(mean, sd), function(g) g(1:3))islist[double], even thoughmeanandsddeclare optional formals the callback never passes.fn(a: integer, b: integer) -> integeris rejected there, because the interface never suppliesb, andfn() -> integeris rejected because it cannot receive the argument the interface sends.fn(count: integer, [label]: character) -> integerdoes not acceptfunction(count, label) countin a checked annotation, becauselabelhas no default. At an argument position this is not yet checked, which is a known gap.
Variadic compatibility is conservative. A variadic function type is compatible only with another
variadic function type, never with a fixed-arity one in either direction. Their rest element types
are contravariant like any parameter, and both sides must declare the same number of parameters
before ..., because that position decides which parameters callers can fill by position. This
rejects some safe pairings, but never admits an unsound one.
Callback forwarding at variadic call sites
Section titled “Callback forwarding at variadic call sites”R’s apply family calls its callback as FUN(element, ...), forwarding the caller’s extra arguments,
so a callback with more formals than the interface declares is still correct when the call forwards
the difference. At a call to a variadic function, a function-typed argument that fails the plain
interface check is therefore checked again, as that forwarded call:
- Forwarded named arguments fill the callback’s formals of the same name first, each checked against its formal’s type.
- The interface’s own parameter types then fill the remaining formals in order, followed by the forwarded positional arguments.
- Any formal left unfilled must have a default.
- The callback’s return type must satisfy the interface’s return type.
- If the second check fails, it binds nothing, and the reported error is the plain interface mismatch.
lapply(words, gsub, pattern = "a", replacement = "o") is therefore checked as
gsub(word, pattern = "a", replacement = "o") and is list[character], and lapply(words, nchar)
accepts nchar’s optional formals. A forwarded argument of the wrong type fails the check, and the
call is an error.
Reporting a function that does not fit
Section titled “Reporting a function that does not fit”When a function value is rejected at a parameter, the finding names the one position that failed rather than printing both whole signatures: either the parameter to which the interface passes a value the function will not take, or the function’s return, which the interface will not take. An arity mismatch still shows both signatures.
Higher-order function types
Section titled “Higher-order function types”A function type may appear inside another function type, as in
fn(transform: fn(integer) -> character) -> character. A nested function type cannot carry its own
binder, because binders are rank-1.
The expanded form can use a function type directly:
#: @param render_count {fn(integer) -> character}#: @param count {integer}#: @return {character}apply_renderer <- function(render_count, count) { render_count(count) }Type annotations and assertions
Section titled “Type annotations and assertions”Checked annotations
Section titled “Checked annotations”#: TYPE is a checked annotation: the annotated value must be compatible with TYPE. Checking is
based on compatibility rather than exact equality, so it allows widening wherever the rules on this
page define it. When the check succeeds, the value is accepted (through a coercion if one is needed),
and the annotated binding or expression has the type TYPE from then on.
#: list[integer]value <- list(1L, 2L, 3L)This is valid, because list{integer, integer, integer} is compatible with list[integer].
Unknown-only coercions
Section titled “Unknown-only coercions”#: @if-unknown TYPE fills a gap in inference. It is allowed only when the inferred type is
Unknown, and it then gives the annotated binding or expression the type TYPE. When the checker
already knows the type, @if-unknown is an error, even if the requested type matches the known one,
so it can never override information the checker has.
#: @if-unknown integervalue <- unsupported_valueThis is valid only if unsupported_value is Unknown.
#: @if-unknown integervalue <- 1LThis is an error, because the checker already knows the type.
Trusted coercions
Section titled “Trusted coercions”#: @trust TYPE is the unchecked override: it tells the checker to treat the annotated value as
TYPE without requiring the usual compatibility at that site. It has the same effect as coercing the
value to Any and then to TYPE, and exists as a shorter way to write that.
Any annotation on a value gives it a type, so strict mode no longer reports the undetermined origin it covers.
#: @trust integervalue <- external_input#: @trust fn(count: integer) -> characterrender_count <- callbackA trusted coercion silences a real error as readily as a false one, so use it only when you know more than the checker does.
Nominal introduction
Section titled “Nominal introduction”#: @new NOMINAL_TYPE is the one way to create a value of a nominal type. The annotated value must
be compatible with the nominal type’s representation, and when it is, the annotated binding or
expression has the nominal type from then on.
NOMINAL_TYPEmust refer to a nominal type declared with@type, either by its bare name, such asPerson, or as a generic application, such asPerson<integer>. An alias, a structural type, a union, a function type, or any other type form is not allowed after@new.- A generic nominal may be written unapplied.
@new Personon aPerson<T>infers the type arguments from the representation check, so a value oflist{value: 1L}becomes aPerson<integer>. - When the value already has the nominal type,
@newis allowed and does nothing further. @newis an annotation form, not a type expression, so it cannot appear inside a compact type or an expanded function annotation.- A checked annotation such as
#: Personon a structural value is a type error, even when the value matches the representation. The checked form asserts that the value already has the nominal type; it does not create one.
#: @type Person {list{ name: character, age: double }}
#: @new Personvalue <- list(name = "bob", age = 20)#: @type Person<T> {list{ value: T }}
#: @new Person<integer>value <- list(value = 1L)#: @type Person {list{ name: character, age: double }}
#: Personvalue <- list(name = "bob", age = 20)The third example is an error: an ordinary checked annotation with a nominal type requires the value
to be a Person already.
Operators
Section titled “Operators”Operators over union operands
Section titled “Operators over union operands”Control-flow joins and mixed containers produce union-typed operands, so every operator below accepts a union member by member. A union operand is accepted when every member is accepted; one unacceptable member rejects the whole operand, and the diagnostic shows the full union. The result is the join of the per-member results, which for a binary operator means the join over every pair of left and right members.
(integer | double) + integerisinteger | double, becauseinteger + integerisintegeranddouble + integerisdouble.(integer | double) > 0Lislogical.(integer | character) + 1Lis a type error, because thecharactermember is not numeric.(integer | NULL) + 1Lis a type error, because theNULLmember is not numeric.rec$aonlist{a: integer} | list{a: character}isinteger | character. A member that lacks the field contributesNULLinstead, as indexing describes.- A
forloop overinteger[] | character[]binds its variable asinteger | character.
Conditions
Section titled “Conditions”if, while, and the operands of && and || all take a scalar condition, and R decides what a
scalar condition admits:
logicalis the ordinary case.integeranddoubleare accepted, and they coerce exactly as R coerces them: zero is false, and anything else is true. That makesif (length(x)),if (nrow(df)), andwhile (n)ordinary R rather than mistakes.character,complex, andraware type errors. R refusescomplexandrawoutright. For acharactercondition, R accepts only spellings ofTRUEandFALSEsuch as"T"and"true", and fails at run time on every other string, soif ("yes")is reported.- A vector is a type error, because a condition whose length is not one is an error in R too.
- A condition whose type is still undetermined becomes
logical. That is the useful default for an unannotated predicate, sofunction(flag) if (flag) 1Linfersflag: logical.
! coerces its operand the same way, so !0 is TRUE and !5 is FALSE; see unary !
for the result.
Indexing
Section titled “Indexing”[[ extracts a single element, and [ is R’s general subsetting operator, defined here for the
vector and list shapes. [ with an out-of-range position or a missing name gives NA at run time,
which is a property of the value, not of its type. [[ with a missing name gives NULL on a list,
which the rules below record as | NULL; they record the same for a named vector, where R instead
fails at run time.
$name behaves like [["name"]] on lists, on records, and on the opaque nominals where access is
tolerated, and a backtick-quoted name follows the same rule. It does not work on atomic vectors,
because R rejects $ on every atomic vector, named ones included: c(foo = 1L)$foo is a type error,
while c(foo = 1L)[["foo"]] is integer | NULL.
On a union, a field may be missing from some of the members. R answers NULL for a name a list does
not have, so a field present in only some of the subject’s shapes reads as that field’s type unioned
with NULL. Code that builds a list field by field therefore checks:
args <- list()if (escape) args$escape <- TRUEargs$escape # logical | NULLA field that no member has is still an error, because that is a typo rather than an absence the program is prepared for. The “did you mean” suggestion draws on every field that any member has.
[[ on vectors
Section titled “[[ on vectors”- A scalar-like
TreturnsT. - An array-like
T[]returnsT. - A map-like
T[named]returnsT | NULLfor a name index.
[[ on lists
Section titled “[[ on lists”- A list-like
list[T]returnsT. - A dict-like
list[named: T]returnsT | NULLfor a name index, andTfor a positional or computed one. - A tuple-like list returns the element at a literal position. A position that does not exist is an error, and a computed position returns the union of the item types.
- A record-like list returns the field at a literal name or literal position. A name or position that does not exist is an error, and a computed index returns the union of the field types, which is how a call to a function looked up in a list gets its type.
[ on vectors
Section titled “[ on vectors”The result of [ on a vector depends on the shape of the subject and the shape of the index. The
index shapes are:
- A scalar-like
integer,double, orcharacterindex selects one position and gives the scalar element type. That is not always exact, since a scalar negative index such asx[-1]drops one element and returns the rest, but a scalar coerces into every vector position, so the claim can never cause a false error later. - An array-like or map-like numeric or character index, such as
x[c(1L, 3L)], selects many positions and keeps the subject’s shape. - A
logicalindex of any shape is a mask, as inx[x > 0], and keeps the subject’s shape. A scalarTRUEorFALSEis recycled over the whole vector. NULLselects nothing and gives the array-like vector of the element type.- An index whose shape is undetermined, such as an unannotated parameter or an
Unknown, counts as scalar-like and is left unconstrained. - A
complexorrawindex is a type error, as is a list, a function, or any other index that is not a vector.
With E as the element type, a scalar numeric or character index gives E on every shape. Any
other index gives E[] on E and E[], and E[named] on E[named], because [ keeps names.
A character index is allowed on every vector shape, not only a map-like one. R returns NA rather
than failing when the subject has no names, and most operations erase names, so requiring a map-like
subject would flag legal programs.
c(1L, 2L, 3L)[2L]isinteger.c(1L, 2L, 3L)[c(1L, 3L)]isinteger[].x[x > 0]onx: double[]isdouble[].c(a = 1L, b = 2L)[c("a", "b")]isinteger[named].x[list(1)]is a type error.
[ on lists
Section titled “[ on lists”[ slices a list, so a fixed shape does not survive into the result:
- A list-like
list[T]returnslist[T]. - A dict-like
list[named: T]returnslist[named: T]. - A tuple-like list returns
list[T], whereTis the union of the item types, solist(1L, "foo")[1L]islist[integer | character]. - A record-like list returns
list[named: T], whereTis the union of the field types. - Slicing the empty list gives
list[NULL].
Indexing a value whose shape is unknown
Section titled “Indexing a value whose shape is unknown”$, [[, and [ on a value whose shape the checker has not determined give Unknown, and leave the
value unconstrained. An unannotated parameter is such a value, as in function(node) node$value,
function(x) x[[1L]], and function(x) x[1L], and none of these reports a “not a list” or
“unsupported [” error.
Ordinary R reads fields, elements, and slices off values whose shape nobody wrote down. A tree fold does it, and so does a generic accessor, so refusing here would report correct code. The access is therefore left undescribed rather than refused, and reported as an unsupported construct under strict mode, exactly as for an opaque nominal.
That covers indexing with several indices too: function(m, i, j) m[i, j] is silent, because such a
function is written for a caller that knows the shape even though the callee does not. A subject
whose shape was written down still refuses a shape no rule covers, so c(1L, 2L)[1L, 2L] is an
error.
Unannotated values in arithmetic
Section titled “Unannotated values in arithmetic”An unannotated value used as an arithmetic operand is required to be numeric rather than rejected:
it must end up as integer or double, in any vector shape, or as a class that declares an
arithmetic operator method.
function(x) x + 1Lis<T: numeric> fn(x: T) -> T, and calling it with"oops"is a type error.function(x) x / 2is<T: numeric> fn(x: T) -> double.function(a, b) a + bis<T: numeric> fn(a: T, b: T) -> T.
When the other operand is a vector or a union, an unannotated operand is pinned to a scalar
double, which matches how R treats bare numbers: f <- function(x) x + c(1L, 2L) is
fn(x: double) -> double[].
Arithmetic operators
Section titled “Arithmetic operators”The arithmetic operators are defined for numeric operands:
integeranddouble;logical, which R promotes tointegerbefore arithmetic, soTRUE + TRUEis2L. A logical operand therefore computes asinteger, and the result rules below need no logical case;- an unannotated value, which is then required to be numeric;
- a class that declares an operator method.
A map-like vector takes part through its compatibility with an array-like vector, so arithmetic does not preserve map-likeness.
An operand whose shape is still unknown, such as an unannotated parameter, counts as scalar-like, both
here and in the comparison rules, by the same scalar claim that [ on vectors
makes. The cost is a false error when the other operand is a vector: for the f above, f(c(1, 2))
is reported, because x was pinned to a scalar.
A generic vector written T[] is the exception, and its operator results really are vectors.
Result shapes
Section titled “Result shapes”Every arithmetic operator uses the same shape rule: the result is scalar-like when both operands are
scalar-like, and array-like otherwise. A map-like operand therefore gives an array-like result.
Unary - keeps a scalar-like or array-like operand’s shape. Only the atomic type of the result
differs between operators:
-
+,-,*,%%, and%/%giveintegerwhen both operands areinteger, anddoubleotherwise. -
/,^, and**(R’s parser alias for^) always givedouble. -
Unary
-keeps its operand’s atomic type. -
integer + integerisinteger. -
integer - doubleisdouble. -
double * integer[]isdouble[]. -
integer[named] + integerisinteger[]. -
integer / integerisdouble. -
2L ^ 3Lisdouble. -
-c(foo = 1L, bar = 2L)isinteger[].
Every %op% operator other than %% and %/% is covered under
operator methods on a class.
Operator methods on a class
Section titled “Operator methods on a class”When an operand of an operator is a nominal type, the operator first dispatches to the class’s
declared operator method, before any numeric rule applies. This is how R dispatches d + 30L on a
Date through +.Date.
The lookup follows R’s own order: first the operator-specific method, such as +.Date; then the
operator’s S3 group generic, which is Arith.Date for arithmetic and Compare.Date for comparison;
and finally Ops.Date. Either operand’s class may supply the method, the left one first, so
30L + d behaves like d + 30L.
A method is declared under the name R gives it, in a stub or an annotation, so the result stays
precise for each pairing of operands: subtracting two Date values gives a difftime, and offsetting
one by a count gives a Date. A class that declares an operator, but has no candidate that accepts
the operands at hand, is a type-mismatch error rather than a fall-through to the numeric rules, just
as in R. A class that declares nothing does fall through to the numeric rules, so arithmetic on an
opaque nominal is still a type error.
Your own classes count. A method declared anywhere the global scope reaches, which includes a
package’s R/ sources and a script’s own top level, makes its class support that operator exactly as
a shipped stub does. That is also how the class satisfies the numeric requirement: passing a Money
to function(x) x + 1L is accepted when the project defines +.Money, and refused when it defines
no arithmetic method.
c() dispatches the same way. A class that declares a c.Class method keeps its class through
concatenation, so c(d1, d2) on two Date values is a Date. A nominal with no such method loses
its class but keeps its data, as R’s default c() does, so two values of @type Money {double}
combine to double[]. An opaque nominal has no representation, so combining it gives Unknown.
The method name’s suffix is the nominal type’s name, not R’s full class vector, so a class declared
as @type ggplot takes +.ggplot, even though R registers the method as +.gg.
a %op% b is the call `%op%`(a, b). A %…% operator that the standard-library stubs declare
is checked and typed as that call, so "a" %in% valid is logical and m %*% m is a matrix.
Every other %…% operator, including a project’s own, gives Unknown, because a user operator may
quote its right operand instead of evaluating it, as magrittr’s %>% does. Using any %…% operator
still counts as a read of its name, so a project’s own operator is never reported as unused.
Comparison operators
Section titled “Comparison operators”<, <=, >, >=, ==, and != compare two operands of the same comparison family. There are
two families:
- the numeric family, which holds
logical,integer, anddouble, freely mixed. R promotes a logical operand tointegerbefore comparing, exactly as it does for arithmetic, soflags > 0andflag == TRUEare ordinary numeric comparisons; - the character family, which holds
character.
Both operands must belong to the same family, and comparing across families is a type error.
complex and raw operands are not supported: complex numbers have no order, and comparing raw
bytes is rare enough not to model. A map-like vector takes part through its
compatibility with an array-like vector.
An unannotated operand is required to be numeric when the other operand is concretely numeric, and
otherwise left unconstrained. When neither operand is concrete, both stay unconstrained, so
function(a, b) a < b is <T, U> fn(a: T, b: U) -> logical, and calling it across families is
accepted. There is deliberately no “comparable” constraint: R’s comparison coerces across atomic
families at run time, so 1 < "2" is legal R, and tying undetermined operands to each other or to a
family would reject legal programs. The same-family rule applies only where both families are
concretely known.
The result is always logical: scalar-like when both operands are scalar-like, and array-like
otherwise.
1L < 2Lislogical.1L == 1.5islogical."a" < "b"islogical.c(1L, 2L) > 1Lislogical[].c(TRUE, FALSE) > 0islogical[].1L < "a"is a type error.
Unary !
Section titled “Unary !”Logical negation coerces its operand exactly as a scalar condition does, and its result is always logical:
!logicalislogical.!integerand!doublearelogical, because R treats zero as false and every other number as true.!logical[],!integer[], and!double[]arelogical[].- A map-like operand gives
logical[], because negation does not preserve map-likeness. - An
AnyorUnknownoperand givesUnknown. - An operand whose type is still undetermined is constrained to
logical, and the result islogical. - Any other operand is a type error.
Range operator :
Section titled “Range operator :”from:to builds a numeric sequence. Both operands must be a scalar-like integer or double; an
array-like or non-numeric operand is a type error. The result is integer[] when both operands are
integer, and double[] when either is double. A whole-number double literal such as 1 or 10
counts as integer here, matching what R does at run time.
An unannotated operand, as in 1:n, is required to be a scalar integer or double. Passing a
numeric vector through the enclosing function is therefore a type error at the call, because R’s
warning about truncating the endpoint marks a bug. The result is double[], because the endpoint may
turn out to be a double.
1L:10Lisinteger[].1:10isinteger[], even though the literals aredouble, because both are whole numbers.1.5:3Lisdouble[].x:10Lisdouble[]whenxis adouble.
Combine c(...)
Section titled “Combine c(...)”c(...) builds an atomic vector from atomic arguments of any shape:
-
With no arguments,
c()isNULL, as in R. -
NULLarguments are dropped, as in R, soc(x, NULL)isc(x)andc(NULL)isNULL. -
A union-typed argument takes part member by member. Its
NULLmembers are dropped first, because at run time the value is eitherNULL, whichcdrops, or one of the other members. Every remaining member must be an atomic vector type, and joins the coercion like a separate argument. An accumulator that starts out asNULLtherefore combines cleanly: withaccof typedouble[] | NULL,c(acc, 1.0)isdouble[]. -
When any argument is a list,
cconcatenates into a list instead of an atomic vector, sincec(list_a, list_b)is R’s standard way to append to a list. The result is a list-likelist[T]whose element type is the join of every argument’s elements, with an atomic argument contributing its own type, soc(list(1L), "a")islist[integer | character]. The atomic coercion rules below apply only when no argument is a list. -
An argument whose element type is not known statically, such as
Any,Unknown, or an unannotated parameter (as infunction(x) c(x, 1L)), is tolerated rather than rejected, and the whole result isUnknown. Under strict mode, an unannotated parameter makes thec()call the origin, anUnknownargument was already reported where it arose, and anAnyargument is not reported. This keepscfrom reporting a false “expectedinteger, foundT” in a generic wrapper, and from cascading on a value that is alreadyUnknown. Claiming a concrete element type would be unsound, because a later argument could widen it. -
Mixed atomic arguments coerce to the widest type in R’s order,
logical < integer < double < complex < character.rawis not part of the order and combines only withraw. -
When every argument is named, the result is a map-like
T[named]; otherwise it is an array-likeT[]. -
c(1L, 2L)isinteger[]. -
c(1L, 2.5)isdouble[]. -
c(TRUE, 1L)isinteger[]. -
c(1L, NA)isinteger[]. -
c(1L, "a")ischaracter[]. -
c(foo = 1L, bar = 2L)isinteger[named]. -
c(list(1L), list(2L))islist[integer]. -
function(x) c(x, 1L)is<T> fn(x: T) -> Unknown, because the unannotatedxleaves the element type indeterminate.
Assignment operator <-
Section titled “Assignment operator <-”name <- expr writes the type of expr into the variable slot for name in the current scope,
creating the slot on the first write (see value names for the slot model). The
assignment expression itself has the type of expr, so y <- (x <- 1L) gives both x and y the
type integer. When the assignment carries a typing annotation, expr is checked by the annotation
rules on this page.
- A string in place of the name binds that name:
"x" <- 1isx <- 1, and so is`x` <- 1. All three forms create the same slot, and a laterxresolves to it. For a string target, the range of any finding is the literal, quotes included, because that is what was written. - A target that is not a name, such as a computed value or a number, is reported as a
syntax-error. R parses these and refuses them at run time. A target headed by!is exempt, because!binds tighter than<-, so rlang’sexpr(!!name <- value)builds an assignment instead of performing one. - A later assignment in the same scope writes the same variable. On a straight-line path the new write replaces the old type, and writes that arrive from different control-flow paths are joined (see control-flow joins).
So after x <- 1L, x is integer; after x <- 1L; x <- "foo", a later x is character; and
after x <- 1L; if (flag) x <- "foo", a later x is integer | character.
Recursion
Section titled “Recursion”A function can call itself, because its own name is visible inside its body: the target is bound to a
fresh type variable before the body is inferred, and that variable is then unified with the inferred
function type. fact <- function(k) if (k <= 1L) 1L else k * fact(k - 1L) is therefore
fn(k: integer) -> integer, and a call that violates the recursively inferred signature is an error.
The recursive uses share one instantiation, so there is no polymorphic recursion.
Two local functions that call each other are beyond this, because names are bound one at a time. The
forward reference still resolves, since a closure sees later writes to its frame, but it stays
tolerant Unknown rather than precisely typed.
At a package’s top level, a self-recursive definition and a group of mutually recursive definitions
both resolve through the interface fixpoint. Every member starts as Unknown and is re-derived each
round until the schemes stop changing. Simple recursion converges to its precise type: a top-level
fact <- function(n) if (n <= 1L) 1L else n * fact(n - 1L) exports fn(n: integer) -> integer, and
the pair is_even and is_odd exports <T: numeric> fn(n: T) -> logical.
A self-reference whose type would grow without bound cannot converge in a type system without
recursive types. A tree fold is one: its parameter would need the recursive type
T = double | list[T]. Such a group settles at Unknown. A cycle can also converge with an Unknown
inside it, and a pure self-call such as f <- function() f() settles at fn() -> Unknown. Either
way the Unknown is the usual tolerance: an unannotated consumer flows through it, and strict mode
reports it (see strict mode). An explicit annotation on the
binding closes the cycle exactly.
Boolean operators && and ||
Section titled “Boolean operators && and ||”Both operands are scalar conditions, so each must be a logical or a number that
coerces, and the result is a scalar logical. An array-like or map-like logical vector is not
accepted.
TRUE && FALSEislogical.flag || other_flagislogical.c(TRUE, FALSE) && TRUEis a type error.TRUE || c(FALSE, TRUE)is a type error.
Function calls
Section titled “Function calls”A call evaluates to its callee’s return type. When the callee expression or its return type is
Unknown, the call is Unknown. When the callee is a union whose members are all function types,
the call must be valid against every member, since the value could be any of them, and it evaluates
to the union of the members’ return types. Each member is checked in an isolated probe, so no
member’s argument bindings leak into another’s. Calling a function looked up in a list, as in
handlers[[name]](...), has this shape.
A call is a type error when a required argument is missing, when it passes too many arguments to a callee without a rest parameter, or when an argument is incompatible with its parameter’s type. The named, positional, and optional parameter rules apply throughout.
Matching arguments to parameters
Section titled “Matching arguments to parameters”Arguments are matched in R’s two passes, and the order is observable:
- Every argument given by name claims the parameter of that name, before any positional argument is placed.
- The positional arguments then fill what is left: first the fixed positional parameters, then the unclaimed named parameters declared before the rest parameter, in declaration order. When the function is not variadic, they fill all the unclaimed named parameters.
- The rest parameter absorbs whatever remains, positional or named.
So in vapply(xs, character(1), FUN = f), the named FUN is claimed first, and character(1)
reaches FUN.VALUE, exactly as R matches it. A positional argument never collides with a parameter
that a later named argument has already claimed.
A rest parameter follows R’s rules for the formals around the dots:
- It adds no required arguments, so a variadic function may be called with none;
paste()is legal. - Positional arguments fill the parameters declared before it first, so
wrap("a", "b")onfn(x: character, ...: character)givesx = "a"and sends"b"to the rest. - It then absorbs any number of remaining positional arguments, each checked against its element type.
- A parameter declared after it can only be matched by name, so
sum(1, 2, na.rm = TRUE)onfn(...: integer[] | logical[], [na.rm]: logical)sends1and2to the rest andna.rmby name. - It also absorbs a named argument that matches no declared parameter, which is how a wrapper passes
an option through, as in
read.csv(file, colClasses = "character"). - A named argument that duplicates a parameter already given is still an error, because R rejects a formal matched twice.
When an argument is the enclosing function’s bare ..., it forwards an unknown number of arguments,
possibly none, as in a wrapper function(x, ...) helper(x, ...). Such a call skips both arity
checks, because neither “missing required argument” nor “too many arguments” can be decided
statically, and the ... argument itself matches no parameter. The call’s other arguments are still
checked against their parameters as usual.
Argument compatibility
Section titled “Argument compatibility”Arguments are checked for compatibility, not exact equality:
- The ordinary coercions apply at parameter positions, including a scalar-like
Tinto an array-likeT[], andTorNULLintoT | NULL. - The promotion order from vector coercions applies, so
mean(1L),sd(c(1L, 2L)), andsum(x > threshold)are not errors. - A whole-number
doubleliteral counts asintegerat a parameter, so for a function annotatedfn(n: integer),f(10)is as valid asf(10L). This generalizes the rule that the:operator applies to its endpoints. A fractional literal such as2.5is still rejected at anintegerparameter, and so is adoublevariable that happens to hold a whole number.-3is unary minus applied to a literal, not a literal, sof(-3)is rejected too. - An argument whose type is
Unknownis accepted at any parameter. Whatever made the valueUnknownwas already diagnosed where it happened, and repeating it at every later use would add nothing.
The native pipe
Section titled “The native pipe”R’s parser rewrites x |> f(y) into f(x, y) before anything runs, so the pipe types exactly like
that call. The piped value becomes the first positional argument, and every call rule applies to it:
arity, argument compatibility, and overload selection. A chain composes from left to right, and a
type error in the piped value is reported on the left-hand expression.
The _ placeholder follows R’s rule: it is legal only as the entire value of exactly one named
argument, which then receives the piped value instead of the first positional slot. So
x |> lm(y ~ z, data = _) is lm(y ~ z, data = x), and 2 |> f(tag = _) supplies only tag, which
leaves any other required parameter missing.
A pipe that R itself would reject is not guessed at. That covers a right-hand side that is not a
call, a positional or repeated _, and a _ nested inside a subexpression. Such a pipe stays an
opaque operator: it types as a silent Unknown, and its reads stay quiet.
Overload sets
Section titled “Overload sets”A standard-library stub may declare several signatures for one name, forming an ordered overload set (authoring stubs describes how). A call to such a name is resolved separately at each call site:
- Candidates are tried in declaration order, and the call commits to the first one whose parameters
accept the arguments. That candidate’s return type is the call’s type, so
sum(1L, 2L)isintegerandsum(1.5, 2.5)isdouble. - Each failed candidate is probed in isolation, so nothing it bound leaks into the next candidate or into the committed result.
- A candidate that leaves the caller’s undetermined types as they were beats one that constrains
them, whatever the declaration order. A wrapper like
function(x) sum(x)therefore keeps its parameter open, because theAnyfallback accepts it without constraining anything. Strict mode reports a call that resolves to such a fallback, because the declarations could not describe it. When only one candidate fits, it is selected and whatever it determines stands. - When several candidates fit only by narrowing the caller’s undetermined types, the call keeps that
narrowing only if they all narrow the same way. When they disagree, no single signature states the
type, so it becomes
Unknown, and so does the call:function(xs) Filter(f, xs)infersfn(xs: Unknown) -> Unknownand accepts both a vector and a list, instead of being pinned to whichever shape was declared first. A function literal among the arguments does not belong to the caller, so narrowing its parameters is no guess: that is howlapply(xs, function(v) v + 1L)givesva type. - The whole-number literal rule does not influence which candidate is
selected. Candidates are first tried against the arguments’ true types, so
sum(1, 2)selects thedoublecandidate, matching what R computes. Only if no candidate accepts them is the set tried again with literals allowed as integers, so a name whose only fitting candidate wantsintegerstill acceptsfoo(1). - When no candidate accepts the arguments, the call is a type error. Normally the error names the overloaded callee and how many signatures were tried, and gives the first candidate’s failure as a concrete hint, because when the candidates disagree about what is wrong, no single complaint is the answer. In two cases one candidate’s own finding is reported instead, at its own argument range: when every candidate rejects the call for the same reason, and when one candidate got strictly further into the argument list than all the others, which makes it the signature the call meant.
- Passing an overloaded name as a value, or hovering over it, sees the last declaration. By convention the last declaration is the most general, so a value never carries a narrower contract than the calls it might make. Go-to-definition jumps to the first declaration, where the set begins.
Only a declaration file can overload a name. A name with several signatures has no single most
general type, so a call must be resolved by search rather than inferred, which gives up the
principal-type guarantee. A #: annotation on your own function declares exactly one signature; to
make one name accept several shapes, give the parameter a union type, or split the
shapes into separate functions.
A local or package binding that shadows a stub name switches its overload set off, and the binding is
used everywhere, calls included. A project override stub
may declare overload sets, because a .Rtypes file is a declaration file for foreign code wherever
it lives.
Control flow
Section titled “Control flow”if expressions
Section titled “if expressions”if without else
Section titled “if without else”An if without else takes a scalar condition. If the branch has type T, the whole
expression is T | NULL, because the missing branch contributes NULL. The usual union
normalization applies, so a NULL body stays NULL, a body that is already nullable stays a single
T | NULL, and an Unknown body stays Unknown.
if (flag) 1Lisinteger | NULL.if (flag) { }isNULL.
if ... else
Section titled “if ... else”An if ... else takes a scalar condition and joins the types of its two branches into
the result:
- Branches that unify share that type.
if (flag) 1L else 2Lisinteger, andif (cond) a else bover two unconstrained values keeps them unified as one polymorphic type. - A
NULLbranch joins by union without constraining the other branch, so one branch of typeTand one ofNULLgiveT | NULL. - Branches with genuinely different types give their union:
if (flag) 1L else "foo"isinteger | character. Different branch types are not a type error. - A branch whose type is still undetermined is never pinned by the other branch.
function(flag, x) if (flag) x else "s"is<T> fn(flag: logical, x: T) -> T | character, notfn(flag: logical, x: character). Unifying there would make the caller wrong because of a line that is not wrong. Guards need the same thing:if (is.character(x)) x else "other"exists precisely for callers that may pass something else. - A branch whose variable the body has already constrained may unify with the other branch, because
that adds nothing the program did not already require. That is how
function(n) if (n <= 1L) 1L else n * fact(n - 1L)converges tofn(n: integer) -> integer. - Two branches that are both still open are tied to each other, because neither pins the other.
function(value, fallback) if (is.null(value)) fallback else valueis<T> fn(value: T | NULL, fallback: T) -> T. - An
Unknownbranch makes the whole conditionalUnknown, rather than claiming the other branch’s type.
Beyond unification, the join does not merge or coerce branch types; it only records the alternatives.
if (flag) 1L else 2Lisinteger.if (flag) 1L else NULLisinteger | NULL.if (flag) NULL else 2Lisinteger | NULL.if (flag) 1L else "foo"isinteger | character.if (flag) { } else { }isNULL.if (c(TRUE, FALSE)) 1L else 2Lis invalid, because a condition must be a scalar, not a vector.
Diverging branches
Section titled “Diverging branches”A branch diverges when it never falls through to the code after the if: return(...),
stop(...), break, or next, a block ending in one of those, or an if ... else whose branches
both diverge. A diverging branch contributes neither its value nor the state of its variables:
x <- if (c) return(NULL) else 5givesxthe typedouble, notNULL | double.- A variable written inside a diverging branch does not join into the state after the
if; only the surviving branch’s state flows on.
A call that never returns has no bottom type of its own. function() stop("no") is
fn() -> Unknown, and divergence is carried by the control-flow rule above, not by the call’s type.
stop(...) is recognized by its bare name, as local and return are, so rebinding stop is not
modelled.
Guard narrowing
Section titled “Guard narrowing”A condition that applies a type-guard predicate to a plain local variable refines that variable’s
type along each edge of the if. Inside each branch, the variable keeps the refined type until a
write in the branch replaces it, and the refinements merge back at the join exactly as branch writes
do. The recognized guards are these, where x is a local variable (a parameter counts):
is.null(x)makesxaNULLin the true branch, and removesNULLfrom its union in the false branch.is.character(x),is.logical(x),is.integer(x),is.double(x),is.function(x), andis.list(x)keep only the members of that family in the true branch, and remove them in the false branch.is.numeric(x)does the same for theintegeranddoublefamily.!condswaps the two branches.
Combined with a diverging branch, the surviving edge’s refinement persists
after the if, which is the idiomatic early-exit guard:
#: fn(x: integer | NULL) -> integerf <- function(x) { if (is.null(x)) { return(0L) } x + 1L # x : integer here}The details:
- A family test covers both the scalar and the vector of that atomic type, so
is.characteris true forcharacterandcharacter[].is.listcovers every list shape (list[T],list[named: T], and the fixed shapes), andis.functioncovers function types. - Narrowing filters union members. A member whose family cannot be decided statically, such as an unannotated value or an opaque nominal, is kept on both edges.
is.null(x)on anAnyorUnknownvariable refines the true edge toNULL, because the runtime guarantees it. A family guard does not refineAnyorUnknown, because inventing a concrete shape there would cause reports on standard-library functions that declare a scalar result for a value of any length.is.null(x)on a value that has no constraints yet, such as an unannotated parameter nothing has used, shapes it. The test asserts thatNULLis a possible value, so the variable becomesT | NULLfor a freshT, and the edges narrow as for an ordinary union: the true edge keepsNULLand the undecidableT, and the false edge isT. That is how a function that returns a fallback for aNULLargument types without any annotation:function(value, fallback) if (is.null(value)) fallback else valuegeneralizes to<T> fn(value: T | NULL, fallback: T) -> T, exactly what its annotated form would declare. Two consequences follow. Testing a parameter forNULLand then using it unguarded is a genuine finding, since the test itself declaredNULLpossible. And the shaping never fires on a variable that already carries a constraint (a numeric variable cannot holdNULL) or on a declared rigid type parameter, because an annotation’s contract is not reshaped.- When a guard cannot fire, such as
is.null(x)on a union with noNULLmember, nothing is refined. Dead branches get no special typing. - Only a read of a variable narrows. That covers parameters, function locals, and a top-level
variable assigned by an earlier statement, but not arbitrary expressions:
is.null(f(x))andis.null(x$field)do not narrow. - A refinement does not outlive the statement it was made in. Checking runs one top-level statement
at a time, so a guard narrows within its own
if, meaning the wholeifandelseand everything nested in them, and the next top-level statement sees the variable’s unrefined type again. This is what separates the two ways to write an early exit. Inside a function body, or inside one braced top-level block,if (is.null(x)) stop(...)narrowsxfor the rest of the body, because the guard and the later reads are one statement. Written as bare top-level statements, the guard and the read after it are two statements, and the read is not narrowed. - A read from inside a closure narrows when the guard is in the same statement, just as a local does. The guard’s own subject must not itself be a deferred read: that body runs later, so the test proves nothing about the value it will see then.
- A condition combined with
&&or||does not narrow. is.na(x)is not a type guard, because beingNAis a property of a value, not of a type.
missing() on a defaultless formal
Section titled “missing() on a defaultless formal”A formal that the body tests with missing(name) is optional at call sites, which is how R writes an
optional argument without a default: function(name, punct) if (missing(punct)) … else …punct… may
be called without punct.
The test also narrows whether the formal was supplied, along the branch edges, like a type guard:
- On the true edge, reading
nameis an error, because R fails that read with “argument is missing, with no default”. Writing it is legal and supplies it, as inif (missing(punct)) punct <- "!". - On the false edge the formal is supplied, and reads are ordinary.
- A diverging true edge, such as
if (missing(x)) stop(...), leaves the rest of the body on the supplied edge, and!missing(name)swaps the edges. - After the branches rejoin, the formal counts as unsupplied only when it is unsupplied on both edges, so only definite failures are reported.
- A formal with a default is never narrowed, because reading it while unsupplied evaluates the default.
- As in R,
missing()applies only to the immediate function’s own formals, so an enclosing function’s formal is not narrowed inside a nested function.
Blocks
Section titled “Blocks”A block evaluates to the type of its last expression, so an empty block is NULL. A trailing ;
adds no expression in R’s grammar, so { 1L; } is integer.
return
Section titled “return”return(x) exits the enclosing function with x, and return() exits with NULL. It is a
control-flow construct rather than a call: the syntactic call to the bare name return is recognized
during lowering, as local is.
- A function’s return type is the union of every
returnvalue in its body with the body’s trailing value, sofunction() { if (c) return("foo"); 5 }isfn() -> character | double. - The
returnexpression itself yields no value where it stands, so it types asNULLlocally and is not a strict-mode origin, just likebreakandnext. - The returned expression is checked like any other expression, and its errors surface as usual.
- A
returninside a loop exits the whole function, so for control flow it abandons the loop iteration the waybreakdoes. - A
returnat the top level is an R runtime error. Its value is still checked, and joins no function’s return type.
switch
Section titled “switch”switch(subject, a = ..., b = ..., default) selects one branch by the subject’s value at run time.
That selection cannot be modelled statically, but the call is still checked in full:
- The subject and every branch are type-checked, and an error inside any branch surfaces as it would anywhere else.
- The call’s type is the union of the branch value types.
NULLjoins the union unless there is a default branch, because an unmatchedswitchreturns an invisibleNULL. A default branch is any branch without a name, including the first. - A named branch with no value falls through to the next branch, as in R, and contributes no type of its own.
- The branches are alternatives, not a sequence. Exactly one runs, so assignments inside them fork and
join exactly like the arms of an
if: a name written in several branches holds the join of what they write, and no branch’s write shadows another’s. A branch that cannot fall through, such as one ending instop(), contributes no state to what follows. A name introduced only inside the branches is not defined on the path where nothing matches, which is what R reports asobject 'r' not found. switchis recognized syntactically by its bare name, as the quoting and masking families are, so a local binding namedswitchmakes the call an ordinary one again.
for (name in value) body needs an iterable source, and binds name to its element type:
- A vector of any shape iterates with its scalar element type.
- A list-like
list[T]and a dict-likelist[named: T]iterate withT. - A tuple-like or record-like list iterates with the union of its item types, which collapses to a
single type for a homogeneous list. A heterogeneous fixed-shape list is therefore iterable:
for (item in list(a = 1L, b = "two")) ...bindsitemasinteger | character. - The empty list
list()iterates withNULL, the union of zero item types. - A union of iterables iterates member by member, so
integer[] | character[]bindsinteger | character. NULLis iterable and runs zero times, which is legal R. It binds the variable asNULL.Anyiterates withAnyitems, andUnknownwithUnknownitems, so a source that already failed does not cause a second error on the loop.- An opaque nominal iterates with
Anyitems, because its element shape is not visible. - Iterating does not constrain a value whose shape is undetermined, such as an unannotated parameter.
R iterates both vectors and lists, and neither may be committed on the caller’s behalf, so the loop
variable becomes
Unknown. - Any other source, such as a function, is an error, reported on the source expression.
The source is evaluated once, before the first iteration, and the loop does not change its type
outside the loop. Inside the body, the loop variable has the element type, and is re-initialized from
the source on every iteration, so an assignment to it in the body does not carry over into the next
iteration. After the loop, R keeps the variable’s last value, but ry does not: inside a function a
read of it is Unknown and not reported even in strict mode, and at the top level of a file it is
reported as unresolved. Both are known gaps.
A while loop takes a scalar condition, which is re-evaluated before every iteration,
so reads in the condition see the loop’s joined state. The loop as a whole evaluates to NULL.
repeat
Section titled “repeat”A repeat loop has no condition and runs its body at least once, so a variable written in the body is
definitely assigned after the loop. It evaluates to NULL.
Naming and scoping
Section titled “Naming and scoping”Project file order
Section titled “Project file order”Project files are ordered the way R collates a package: by the Collate field of DESCRIPTION if it
has one, and otherwise by the default C-locale collation of the source file names. Wherever this
page talks about an earlier or later file, it means earlier or later in this order.
Value names
Section titled “Value names”A top-level value name in a package is global across its files:
- Any file may refer to a top-level binding in another.
- When several files define the same name, the later file wins, and both definitions are reported.
- Every top-level assignment binds a package global, wherever it sits in the statement: in a bare
{ }block, in anif,for, orwhilebody, in a block passed to a call such assuppressWarnings({ x <- f() }), or in the value of another assignment, soy <- (x <- 1L)binds both names. - A name that is written only conditionally has the union of every writer’s type when another file reads it.
A reference from another file sees the binding’s generalized exported type. Type information does not flow back into the exporting file, so a call in one file never changes the inferred type of a function defined in another. Within one file, a top-level name also resolves to its final exported type, so a use placed before the definition still sees it.
Inside executable code, naming is lexical, over mutable variable slots, matching R’s environments: a scope holds one variable per name, and assignment mutates it.
- A function body, a
local(expr)call, and a script’s top level each form one scope, called a frame. - A parameter introduces a slot in the function’s frame, and assigning to the parameter’s name writes that slot.
- The first
<-or=assignment to a name in a frame creates its slot. Every later assignment writes the same slot rather than creating a new, shadowing binding. - An assignment inside a branch or a loop body writes the enclosing frame’s slot, because braces and control flow do not create scopes.
- A slot shadows an outer or package-global binding of the same name. But a slot that no write reaches at a given read does not shadow, and the read resolves outward, just as R’s lookup would at run time.
forintroduces a loop-local slot, re-initialized from the source on every iteration.local(expr)evaluatesexprin a fresh child scope and takes its type. Assignments inside are local, while references still see the enclosing names.
Two families of calls evaluate an argument non-standardly. Each is recognized by its bare name, and rebinding the name to a user function does not change that:
library(pkg),require(pkg), andhelp(topic)read a bare first argument as a package or topic name, solibrary(stats)meanslibrary("stats"). The name is never resolved as a variable and never warned about. A string argument, a named first argument, or a qualified callee makes it an ordinary call.quote(expr),substitute(expr),bquote(expr), andexpression(expr)build an expression instead of running it, so an assignment written inside one binds nothing:quote(x <- 1)leavesxundefined. Nothing inside is checked for arity or argument types, and a name it mentions need not exist. Those names still count as reads, though, becauseevalmay run the expression later.
At a package document’s top level, a conditionally executed assignment is not visible to the rest of
the package, but within its own document it behaves like a slot. A later top-level read resolves to
it, and reports the maybe-undefined warning when a path without the assignment also reaches it. A
read from another item sees the join of every conditional writer’s type, so
for (i in 1:3) total <- i followed by report <- function() total types report as
fn() -> integer. A name with many conditional writers has no useful joined type, so past eight
writers the slot is Unknown.
Name references
Section titled “Name references”A name reference evaluates to the type currently bound to the name. When the name is not bound, the
checker reports it as unresolved, and treats the reference as Unknown from then on, so checking
continues without a cascade of follow-on errors.
Namespace access
Section titled “Namespace access”pkg::name and pkg:::name read one name directly from a package’s namespace, bypassing lexical
scoping.
- A namespace is known when stubs declare it. The shipped standard-library packages are known, and
so is any namespace a project stub file declares:
stubs/dplyr.Rtypesdeclares the namespacedplyr(see stubs). - The project’s own package is always known, whatever the stubs say. Qualifying a name with the
package you are editing, as
withr::defer()does inside the packagewithr, reads the definition the checker already holds, so the read has that definition’s type rather thanUnknown. This wins over a stub namespace of the same name, just as a package binding shadows a stub name. The name itself is not validated, because a package exports names its sources never bind: re-exports, S4 generics fromsetGeneric, datasets underdata/, and bindings installed by.onLoad. A name the definitions do not cover is therefore left alone rather than reported. - When the stubs declare
nameinpkg, the qualified read has the stub’s type, exactly like the bare name. A name that only the namespace’s export manifest lists validates the same way, and types asUnknown. - An unknown namespace warns, and so does a known namespace that neither declares nor lists the name
(“not exported”). The same mistake in a
NAMESPACEimportFromis an error rather than a warning, because a bad import stops the package from loading at all, while a bad qualified read fails only if that line runs. - A conditional namespace, and
any shipped export manifest outside R’s own packages, is unknown until the project uses the
package, and a
pkg::nameread does not count as use. Sodplyr::mutate()in a script with nolibrary(dplyr)and noDESCRIPTIONreportsdplyras unknown. This is a known gap: the read names its package explicitly, which should be enough. - Exports are tracked per declaration. A project stub that overrides a shipped name’s type does not
remove the name from its shipped namespace, so
stats::sdstays valid under ansdoverride. - A qualified read that could not be validated types as
Unknown, and is a strict-mode origin. :::reads an internal name, so a name the namespace does not export is not reported there.
Package imports (NAMESPACE and DESCRIPTION)
Section titled “Package imports (NAMESPACE and DESCRIPTION)”The hosts read the package’s NAMESPACE and DESCRIPTION files at the package root, and what those
files say extends name resolution across the whole package. Without them (a single file, or a project
with neither file), analysis behaves as though both were empty.
importFrom(pkg, name)makesnamea known bare read that is never reported as unresolved. The read has the stub corpus’s type for the name when there is one, andUnknownotherwise. A typo in the import is caught once, at the import: animportFromnaming something a stub-described namespace does not export is an error there, because R refuses to load such a package, and it never shows up at the use sites.import(pkg)of a namespace the stubs describe makes exactlypkg’s exports known bare reads. When no stubs describepkg, its export set cannot be known, so every otherwise-unresolved bare read in the package is tolerated instead of guessed at, and an unknown package never causes a false report. Unresolved-name detection resumes once stubs forpkgexist.- A
library(pkg)orrequire(pkg)call anywhere in the project is the script-world equivalent ofimport(pkg), and follows the same rule. It attaches every export ofpkgto the search path, so when nothing describespkg, every otherwise-unresolved bare read is tolerated, and because R’s search path is project-wide, so is the tolerance. It lifts as soon as the package’s exports are known, which they now are for a long list of common packages. An export manifest is enough; types are not required. The tidyverse,knitr,rlang,glue,jsonlite,R6, and the rest of the shipped manifests therefore keep unresolved-name detection on. A package nothing describes still switches it off, and a two-linestubs/<pkg>.Rtypesof your own switches it back on. - Attaching a meta-package activates the packages it attaches, not the names it exports.
library(tidyverse)makesmutate,read_csv, andstr_to_upperreachable because it attachesdplyr,readr, andstringr, and those namespaces activate with it, types included where they have them. - Attaching or importing the project’s own package, the one named in the
Packagefield ofDESCRIPTION, does not switch the tolerance on, even though no stubs describe it. Its export set is not unknowable: those exports are the project’s own definitions, which the checker already sees. Without this rule, thelibrary(yourpkg)thatusethiswrites intotests/testthat.Rwould turn off unresolved-name detection for the whole package. - A
pkg::nameread of a namespace the stubs do not know warns about an unknown namespace, unlesspkgbelongs to the package’s declared universe: aDESCRIPTIONdependency field (Depends,Imports,Suggests, orEnhances), or the source namespace of anyNAMESPACEimport. A namespace that is declared but not described stays quiet, and its reads type asUnknown.
Standard-library exports
Section titled “Standard-library exports”The shipped stubs pair each namespace with a vendored export manifest: the complete list of names the namespace really exports, generated from a live R session, as authoring stubs describes. Every manifest name is a known global:
- A bare read of a manifest name always resolves and never warns as unresolved. It has the stub’s type
when one exists, and
Unknownotherwise. - A qualified
pkg::nameread of a manifest name validates the same way, with no not-exported warning, and has the same type. - Typo suggestions for names that really are unresolved draw on manifest names as well as on typed declarations.
Manifests follow the way R itself exposes each namespace:
- The default-attached packages,
base,stats,utils,graphics,grDevices,methods, anddatasets, are visible without qualification in every session, so their names resolve bare and qualified, unconditionally. - The packages R ships but does not attach,
tools,parallel,compiler,grid,splines,stats4, andtcltk, are reachable through::in every session, so their manifests always validate a qualified read. A bare read resolves only once the project attaches the package with alibrary()-family call or declares it as a dependency, exactly as in R. - A conditional namespace’s manifest activates together with its stubs (see conditional stub namespaces). While the namespace is inactive, its names stay unknown, bare and qualified alike.
- A read satisfied only by an import still counts as a use for the unused check. A bare read of such
a name is not a strict-mode origin, which is a known gap; a qualified
pkg::nameread with no known type is.
Replacement-form assignment
Section titled “Replacement-form assignment”A replacement-form assignment (x$field <- v, x[["name"]] <- v, x[[key]] <- v, or
x@slot <- v) reads the base variable, applies the write, and writes the result back to the base’s
slot, so the slot’s type reflects the update:
- A write to a known field,
x$field <- vorx[["literal"]] <- v, on a record-likexsets that field’s type to the type ofv, adding the field if it is absent, and a laterx$fieldreads the new type. The same write on an emptylist()starts a recordlist{field: V}. - The same write on a nominal
xis checked rather than applied. A nominal type’s representation is fixed, which is what makes@typean invariant rather than a label, so the write must satisfy it:vis checked against the field’s declared type, a field the representation does not declare is an error, andxkeeps its nominal type either way. This is the one write that reports instead of retyping, because the alternative is a value that still claims a nominal type while no longer matching its representation. - A write with a computed key,
x[[key]] <- vwherekeyis not a literal, cannot name a field statically, so it refines the container’s element type instead:- an empty
list()becomes a dict-likelist[named: V]; - a dict-like
list[named: T]becomeslist[named: T | V]; - a list-like
list[T]becomeslist[T | V], and stays list-like, because its reads are not nullable; - a record-like or tuple-like container is left unchanged. A dynamic write does not statically change a shape whose fields are individually known, and widening such a shape would throw away precision the code has not given up.
- an empty
- The accessor chain and the index or key expressions are ordinary reads, so their own errors surface.
A replacement whose accessor chain has no variable at its root, such as
f(x)$a <- v, is refused as an unsupported construct: it types asUnknownand is a strict-mode origin.
Reading a name from a dict-like list gives T | NULL, because the key may be absent (see
[[ on lists). Building a map with computed-key writes and then reading a key back
therefore gives V | NULL, so guard the read with is.null before a use that needs a V.
Control-flow joins
Section titled “Control-flow joins”A read of a variable sees every write that can reach it, so wherever control flow merges, the variable’s possible states are joined:
-
After an
ifwithoutelse, a variable written in the branch joins its type from before theifwith the type the branch wrote. -
After an
if ... else, a variable joins the outcomes of the two branches, and a branch that does not write it contributes the state from before theif. -
A loop body may run zero or more times, so reads inside the body and after the loop join the state from before the loop with the state flowing around the back edge.
-
repeatruns at least once, so after the loop the variable has the state the body left it in. -
Joining equal types keeps the type, and joining different types gives their union. Joining with
Unknownkeeps the other side, because the fixpoint for a recursive definition starts fromUnknownand could never reach a precise type ifUnknownabsorbed everything it met. -
f <- function(flag) { x <- 1L; if (flag) { x <- 2L }; x }is clean, andxreads asinteger. -
f <- function(flag) { x <- 1L; if (flag) x <- "two"; x + 1L }is a type error, becausexreads asinteger | character, and+rejects thecharactermember.
A variable with exactly one reaching write keeps that write’s generalized type, so
f <- function(x) x inside a body stays <T> fn(x: T) -> T, and so does an alias of it: after
g <- f, both g(1L) and g("a") check. When writes merge at a join, the variable holds a single
type instead, so a conditional reassignment loses the polymorphism: two conditionally assigned
functions read as a union of both signatures, rather than being unified into one. A call on that
union returns the union of their return types. An argument whose type is still open, and which a
member accepts only by narrowing it, becomes Unknown, because no single signature states what the
members demand together.
Definite assignment follows four rules:
- A read that some path reaches with no prior write reports
maybe-undefined, because R raisesobject 'x' not foundon that path. Inside a function, a binding of the same name outside the function would be read instead, which this check does not yet consider. The finding is off by default; turn it on with[check] maybe-undefined = true. Two conditions that always agree at run time are still two branches to the checker, soif (ok) v <- …followed byif (ok) use(v)is reported, although it is safe. - A
repeatis left through itsbreakpoints, so one that always assigns before breaking reports nothing, and nothing after arepeatwithout abreakis reachable. A branch that cannot fall through, such as one ending instop(), contributes no path. - A read that no write can reach does not resolve to the variable at all (see the shadowing rule under value names).
- A top-level variable is different, because a path without a write reaches the enclosing
environment. The read then sees the name’s binding elsewhere in the script or package, and that type
joins into the slot like any other reaching write. After
p <- "word", the body ofwhile (cond) p <- p - 1Lis a type error, because the first iteration reads acharacter. A name with no such binding staysUnknown.
An item whose check reports an error exports Unknown, so one mistake does not cascade across a file.
An item with a #: annotation is the exception: the annotation is what the author says the binding
is, so even when its body violates the annotation, every call site is still checked against the
declared signature.
Unused assignments
Section titled “Unused assignments”With the unused check enabled, an assignment whose value no read can observe on any path reports
unused on the assigned name. Top-level assignments visible to the package, parameters, for
variables, and names starting with . or _ are never reported.
A read inside a nested function is a capture. The closure runs after its frame has finished, so every write to the captured name stays observable, and none of them is a dead store. That holds only for the frame the read resolves to: a same-named binding in an enclosing frame is shadowed, not read, and still warns.
f <- function() { x <- 1L; x <- 2L; y <- x; y }warns that the first write toxis unused.f <- function() { x <- 1L; g <- function() x; x <- 2L; g }is clean, because both writes stay alive through the capture.f <- function() { x <- "outer"; g <- function() { x <- TRUE; function() x } }warns, because the innermost function reads thexofg.
on.exit(expr) reads the same way: R runs the expression when the function returns, so it observes
the last value of every name it mentions. The standard rollback guard is therefore clean:
with_transaction <- function(con, body) { committed <- FALSE on.exit(if (!committed) dbRollback(con)) body(con) committed <- TRUE # read by the exit handler, not a dead store invisible(TRUE)}Names bound at run time
Section titled “Names bound at run time”In a file that calls R6Class, the names self, private, and super resolve. R6 creates those
bindings when an object is constructed, so they exist nowhere lexically, and reading one is not an
unresolved name. The recognition is syntactic, so a local binding that shadows R6Class is not
honored, and it is scoped to the file, so a file that defines no R6 class still warns about self.
These names type as Unknown, because R6 field and method types are not described.
A top-level globalVariables(c("a", "b")) or utils::globalVariables(...) call with literal string
arguments declares those names as bound dynamically for the whole package, which suppresses the
unresolved-name warning for them everywhere. Any undeclared name keeps warning.
Type names
Section titled “Type names”@type and @alias declarations share one project-global namespace:
- A type reference may resolve to a declaration in the same file or another, and forward references are allowed.
- A duplicate type name is an error whatever the declaration kind:
@typetwice,@aliastwice, and one of each all conflict, and every declaration involved in the conflict is marked as erroneous. - A duplicate is judged within the namespace the declaration lives in. Package files share the
project-global namespace, so two package files that declare one name conflict. A script’s
declarations belong to that script alone: a name declared in one script is invisible to the next,
so two scripts may each declare
Thing, while declaringThingtwice within one script is a duplicate. Without this rule, the later declaration would silently win, and nothing in the visible source could explain the diagnostics it produced. - Type parameters are local binders, and shadow project-global type names.
- A type reference that resolves to nothing is an error at the referencing token, with a hint when a
close match exists. A reference can resolve to a built-in type, a binder in scope, a project
@typeor@aliasdeclaration, or a class declared in a stub. An undeclared name then compares likeUnknowneverywhere, so the typo is reported exactly once and never cascades into mismatches between values.
Non-package documents
Section titled “Non-package documents”A file that is not a package source file, such as a script under scripts/, contributes neither to
the package’s global value namespace nor to the project-global type namespace. A script runs top to
bottom, so its top level is one sequential lexical scope, like a function body:
- A top-level binding is visible only after its assignment, and rebinding a name changes the uses after it, just as local rebinding does.
- A use before any script-local or package-global definition is an unresolved name. That includes a
read inside the very statement that first binds the name, such as
x <- x + 1Lwith no earlierx, which fails at run time. - A read from inside a nested function is deferred. The closure runs after the frame has settled, so it resolves against the whole document, and the last top-level binding of the name wins. That includes the enclosing statement’s own binding, so self-recursion resolves, and a self-recursive closure types through the cycle fixpoint.
- A conditional top-level write, meaning one inside a top-level
if,for,while, orrepeat, creates the document’s variable slot exactly as in package files, and later reads in the same document resolve to it and see the join of the conditional writes. - A masked read, from
withor from data.table indexing, and a read inside an opaque operator, which is&, a user%op%, or a pipe R would reject, are never reported as unresolved. Each still counts as a use: it keeps the binding it would fall back to alive for the unused check, and navigation (goto and references) connects it. A well-formed|>is not opaque, because it types as the call it stands for.
Scripts are type-checked like package files, against the package-global value schemes and project-global types, plus their own local bindings and type declarations:
- A script may resolve package-global value names and project-global
@typeand@aliasnames from package files. - A script’s top-level value bindings and type declarations are visible neither to package files nor to other scripts.
- A package file and a script may reuse the same top-level value or type name without conflict.
- Duplicate top-level value names inside a script do not produce the package-global duplicate-binding warning; they behave like ordinary rebinding. R scripts commonly rely on the global environment, so warning about rebinding there would only add noise.
Data frames and non-standard evaluation
Section titled “Data frames and non-standard evaluation”R evaluates some arguments inside a data frame’s own environment, where a bare name is a column
reference that no lexical scope can see. These masked positions are recognized structurally, and a
read there that resolves to no binding is treated as a column reference: a silent Unknown, with no
unresolved-name warning and no strict-mode origin. The recognized masks are:
- A single
[bracket whose subject types as thedata.tablenominal masks all of its index arguments, whatever they look like. Because the subject’s class is known,DT[speed > 20]andDT[, x]are column references even without any syntactic marker. - A
[call carrying an unambiguous data.table signature masks all of that bracket’s index arguments, even when the subject’s type is unknown. The signatures are aby =orkeyby =argument, a:=column assignment, a.()list call, and the specials.SD,.N,.I,.BY,.GRP, and.EACHI. - The base masking family,
with(),within(),subset(), andtransform(), masks every argument except the data. Which argument is the data follows R’s own matching: a named argument claims its formal first (datafor thewithpair,xforsubset,_datafortransform), and the remaining positional arguments fill what is left. Sowith(data = frame, speed > 20)andwith(speed > 20, data = frame)both mask the condition. Thebase::spelling of any of the four masks exactly like the bare one, while another package’s export of the same name is a different function and masks nothing.
A name inside a mask that does resolve, such as a local variable used in j or a function like
sum, keeps its ordinary resolution and typing, because data.table itself falls back to the lexical
scope for names that are not columns. Base indexing such as m[i, j] carries no data.table marker,
so it keeps full lexical checking. A function written inside a masked argument is masked too, because
a closure created in j is created inside the data’s frame.
data.table result classes
Section titled “data.table result classes”A bracket with a data.table signature but an unknown subject types as Unknown, because base
indexing rules cannot judge [.data.table. When the subject is the data.table nominal, the class
of the result follows from the bracket’s syntax, even though the columns are unknown. Here j is the
second positional slot or a j = argument:
DT[i] # data.table: a row filter or a join returns a tableDT[, x := 1] # data.table, returned invisiblyDT[, .(m = mean(x))] # data.table: `.()` and `list()` build a tableDT[, sum(x), by = g] # data.table: a grouped result is always a tableDT[, x] # Unknown: the shape would need column knowledgeAnything not covered above, such as an ungrouped computed j or a with = form, is Unknown and a
strict-mode origin.
The class is a real type. It flows through chains, so DT[a > 1][, .(m = mean(b)), by = g] stays a
data.table from end to end; it satisfies or violates annotations; and it constrains call
arguments. Column-level knowledge is out of scope: element types, membership checks, and the
evolution of columns through := are not tracked.
Conditional stub namespaces: data.table, dplyr, ggplot2 and testthat
Section titled “Conditional stub namespaces: data.table, dplyr, ggplot2 and testthat”Four packages have shipped stubs that do not join name resolution by default:
data.table, with thedata.tablenominal,fread, and theset*()family;dplyr, with the@maskedverbs, the joins, the tidy-select helpers, and the rest of its vocabulary;ggplot2, with theggplotandggnominals,+.ggplot, the geoms and scales, and the@maskedaes;testthat, with the expectations andtest_that.
R does not attach these packages by default either, and their names must not suppress typo warnings in projects that never use them. So a conditional namespace activates only when:
- the project declares the package, through a
DESCRIPTIONdependency field or aNAMESPACEimportorimportFromthat names it; - any project file attaches it, with a
library(),require(),requireNamespace(), orloadNamespace()call whose package argument is a literal name or string; or - the project ships its own
stubs/<pkg>.Rtypesoverride for the namespace.
While a namespace is inactive, it behaves exactly like any package the stubs do not describe.
The shipped dplyr verbs preserve their data argument’s class, as <T> fn(.data: T, ...) -> T, so a
native-pipe chain keeps its class from end to end: fread(path) |> mutate(r = a / b) stays a
data.table, and every column reference inside those verbs’ ... stays masked.
A project stub can declare its own masking function with the @masked attribute. A dplyr-style verb
is declared like this:
filter : @masked fn(.data: Any, ...: Any) -> Anymutate : @masked fn(.data: Any, ...: Any) -> AnyA call to a @masked name evaluates the arguments absorbed by its ... rest parameter inside the
data’s frame, where a bare name is a column reference. That applies to the bare name and to
pkg::name alike. An argument that matches a formal declared before the ..., such as .data
above, resolves normally, by position or by name. A declaration whose only parameter is ..., such
as join_by : @masked fn(...: Any) -> Any, masks every argument. In all of these cases, a locally
defined function of the same name masks nothing. @masked on a declaration that is not variadic is a
stub error.
Object systems (S3, S4, R6)
Section titled “Object systems (S3, S4, R6)”The checker covers the parts of R’s object systems that are written down as declarations, and stays out of the parts decided at run time from a value’s class attribute:
- An operator on a nominal type (
+.Class,Arith.Class,Ops.Class) dispatches statically; see operator methods on a class. - A directly called S3 method, such as
speak.dog(x), is an ordinary call, checked against that function’s own signature. UseMethod("speak"), and any call to an S3 generic, givesAny, with no strict-mode finding.structure(list(...), class = "dog")keeps the argument’s type, because aclassattribute is data, not a type, so the record’s fields stay checkable. Adimattribute is the exception: it makes the value an array, whose shape is not tracked, so such a value isUnknown.setClass,setGeneric,setMethod, andneware not modeled, sonew(...)isUnknown.x@slot, read or written, is lowered fully and typed asUnknown; see below.R6Class(...),$new(...), and R6 fields and methods are not modeled and areUnknown. Inside an R6 method,self,private, andsuperresolve as names typedUnknown. Strict mode does not report R6 values yet, becauseR6Classis known only from an export list.
x@slot reads an S4 slot and x@slot <- v writes one. The slot’s type is unknown, but the construct
is still analyzed: a slot read types as Unknown and is a strict-mode origin; the subject expression
is inferred, so its own type errors surface; the subject’s variable counts as a read for naming, the
unused check, references, and rename; and a slot write is an ordinary
replacement-form assignment of its base variable.
Declaring a checked type for your own classes
Section titled “Declaring a checked type for your own classes”A class is a nominal type with a representation, which is something you can declare. Wrapping the constructor is enough to get slot types, constructor arity, and field access checked on an S4 or R6 class:
#: @type Point {list{x: double, y: double}}
setClass("Point", representation(x = "numeric", y = "numeric"))
#: fn(x: double, y: double) -> Pointmake_point <- function(x, y) { #: @new Point new("Point", x = x, y = y)}
#: fn(p: Point) -> doublenorm2 <- function(p) sqrt(p$x^2 + p$y^2)
norm2("nope") # type-mismatch: the argument is not a `Point`make_point(1) # type-mismatch: a required argument is missingThe setClass call stays opaque, and the annotation is what the checker reads. Operators on the
class work the same way: declare Arith.Point, and p1 + p2 is checked.
Method dispatch, on the other hand, needs the argument’s class at the call site, and inside an
unannotated function(x) speak(x), that class is not known there.
Strict mode
Section titled “Strict mode”Strict mode is an opt-in check, switched on with [check] strict and off by default. It changes
nothing about inference and adds no typing rules. The type checker already computes every type;
strict mode reads those types and reports the places where the checker genuinely could not determine
one, and it escalates unresolved references to errors. A value typed Any by an annotation or a
single declaration is an intentional opt-out and never produces a strict finding. A call that falls
back to an overload set’s Any candidate is the exception, because the fallback marks a call the
declarations could not describe.
It also reports each read that the attached-package tolerance
silenced, and points at the stub that would make it checkable. Without that,
a clean run after library(unknownpkg) would look the same as a run that checked nothing.
Unresolved references escalate to errors
Section titled “Unresolved references escalate to errors”An unresolved reference carries the unresolved code, and comes in three kinds:
- a bare name the resolver cannot find in the package, its imports, or the builtins;
- an unknown package namespace in
pkg::name; - a name that a known namespace does not export, read as
pkg::name.
Outside strict mode these are warnings; under strict mode they are errors, whether strict mode comes
from the configuration or from the per-file directive, because a name the checker cannot resolve is
an unchecked part of the program. Turning strict mode on can therefore raise the severity of findings
that were already there without changing their count, which matters when a --min-severity error
gate reads them.
Two unresolved findings are errors in every mode, because they stop the package from loading rather
than describing a gap in the checker’s view: a NAMESPACE importFrom naming something the namespace
does not export, and an export() naming something the package never defines.
Per-file directive
Section titled “Per-file directive”A plain top-level comment sets one file’s typing mode, overriding the configured [check] switches
in both directions:
# typing: strictoffsilences the file’s type errors and strict diagnostics, even when the configuration checks types.onopts a single file into type checking, with strict mode off.strictalso switches on strict mode for the file.- The comment must hold nothing but the directive:
# typing: off # reasonis reported as an unknown directive. - A directive must be a comment between top-level expressions. Inside any expression, such as a
function body or an
ifblock, it is ignored. - The older
#: @strictform is still supported:#: @strictmeans# typing: strict, and#: @strict offmeans# typing: on, which type-checks the file but not strictly. - If a file has several directives, the last one wins. A
typing:comment with any other value is reported as an error rather than silently ignored. - The directive changes only which diagnostics are published for the file. Inference and every other
check are untouched, so hover and the other editor features keep working under
off.
Where an undetermined type is reported
Section titled “Where an undetermined type is reported”An undetermined value is reported once, where it first became undetermined, not at every later expression that carries it. There are three such origins: a construct the type system does not describe, a reference to a binding whose type is not known, and a recursive definition the fixpoint could not type.
A reference to a binding defined in this project is not an origin. The binding’s own definition is
reported instead, so one undetermined value does not produce a finding in every file that reads it.
An unresolved bare name is not an origin either, because naming already reports it. An unresolved
pkg::name is currently reported both ways. A value under a #: annotation (checked, @trust,
@if-unknown, or @new) is not an origin, because the annotation gives it a type.
Diagnostics
Section titled “Diagnostics”Strict diagnostics carry the code strict, so they can be filtered separately from type errors. Each
origin is reported once, at the exact range of the originating expression. A binding and a bare
expression are worded differently, because a binding can be annotated and a bare expression cannot.
Syntax errors
Section titled “Syntax errors”A file with syntax errors is still analyzed, under one rule: a broken region reports its syntax error and, strict mode aside, nothing else, because the checker draws no conclusions from source it could not read.
- Every well-formed statement in the file is analyzed normally. Definitions keep their exports, references resolve, and a genuine type error outside the broken region still surfaces.
- A broken statement contributes nothing: no names, no reads, and no diagnostics beyond the syntax error covering it. Strict mode is the exception, and can still report an undetermined type there.
- An unterminated argument or parameter list ends at the next statement, so the mistake stays on the
line that made it. A list that runs onto the next line is ordinary R, and a fragment there such as
beta)really is a forgotten separator and is reported as one. But a line that assigns is the next statement: adopting it would report a confident “missing separator” on that line and every line after it, and cost each adopted line its own definitions. - A broken assignment whose name side is intact keeps its definition. The value becomes a hole that
types as
Unknown, so its dependents neither lose resolution nor see a wrong type while the value is being edited. The hole is not a strict-mode origin, because the syntax error already marks it. - A checked annotation on such a broken definition binds its declared type without checking it, so the definition keeps its contract for callers until the value parses again, and is then checked against the annotation as usual.