The standard library
This draft designs ry’s standard library: a set of typed, native functions modeled on Roc’s builtins, which build on ry’s records, tuples and tags. It settles which modules exist, how they are named and typed, and how they reach a program. Once the library ships, the user contract is the standard library reference.
What it is for
Section titled “What it is for”R has a large base library, but none of it knows ry’s own values. A function that can fail has no
typed way to say so, match has nothing to consume, and R’s collections are either untyped lists or
environments. Roc solves the same problem with a small builtin library organized around Result,
List, Str, Num, Dict and Set, where a failure is a tag rather than an exception (written
Err(ListWasEmpty) in Roc, and :Err(:ListWasEmpty) in ry). ry copies that library where it
fits, so this works and types first_word as fn(text: character) -> character:
first_word <- function(text: character) -> character { Result.with_default(List.first(Str.split_on(text, " ")), "")}Names are a convention, not a namespace object
Section titled “Names are a convention, not a namespace object”A module is a naming convention: Result.map is one ordinary R identifier, bound like any other
function. There is no Result object, so Result$map does not exist, and Result.map needs no new
syntax because . is already part of an R name.
The alternative, a namespace value that holds the functions, was declined for three reasons:
Result.mapalready parses as one symbol, so a namespace object would needResult$map, which reads as a list access and costs a lookup plus a$on every call.- One symbol per function is what the checker, hover, completion and go-to-definition already understand, through the existing stub machinery. A namespace object would need a new kind of type for “module” in a Hindley-Milner checker that has none.
- Static dispatch (
x.map()) is out by directive, so nothing needs the module as a value.
Where the names live
Section titled “Where the names live”The functions live in the ry namespace, which is attached at startup as package:ry in front of
the default packages, beside the DataFrame API (DataFrame, col, setkey and the rest), so search()
starts with ".GlobalEnv", "tools:ry", "package:ry", "package:stats". ry::List.map spells
the same function after something else masks the bare name. (Without base R, as in the
interpreter’s own tests, the builtins bind them in a package:ry just above package:base.)
Placing them there rather than in the base environment is what keeps R programs meaning what they
meant. R looks a name up from the calling environment outwards: top-level code goes through the
global environment and then the search path, and package code goes through its namespace, its
imports, the base namespace, and only then the global environment and the search path. Either way,
every environment a program could have defined a name in comes before package:ry:
- A script that defines its own
List.mapkeeps it, and so does a package. - A package that attaches a package which exports a
Str.trimsees that one, becauselibrary()attaches in front ofpackage:ry. The default packages come after it, which is safe because none of them exports a library name. - S3 dispatch only finds
Result.mapas a method when some code calls a genericResult()on an object of classmap, and no shipped package has generics with these names.
No exported name of base R or of a package with a shipped export manifest (types/*.exports)
starts with Result., List., Str., Num., Bool., Dict. or Set., which a test pins. The
recommended packages (Matrix, MASS, survival and the rest) have no shipped manifest, so the claim is
unverified for them. Names that differ only in case, such as list.files and set.seed, are
distinct R names and are not affected.
Two things do change, and both are deliberate:
- Reflection sees the names.
exists("List.map")isTRUE,search()listspackage:ry, andls("package:ry")lists the library, so a program that branches onexists()before defining a fallback takes the other branch. That is the same as running it under a newer R that added the name. - GNU R does not have them. A script that calls
List.mapand checks clean still fails underry R, which runs GNU R, but with an error that names the cause: the GNU R session’stools:rybinds every library name GNU R lacks to a function that stops with “List.map()is part of ry’s standard library, which only ry’s runtime provides”. Soexists("List.map")isTRUEthere too. PlainRscriptstill says “could not find function”. Records, tags andmatchhave the same split without the friendlier error.
Which modules, and which functions
Section titled “Which modules, and which functions”The names follow Roc’s builtins as they stood before its Result became Try and walk became
fold (roughly the 0.0 releases through 2025), spelled in snake_case. R style guides favor
snake_case too, and Roc’s newer builtins switched to it. That earlier API is the one Roc’s guides
and most of its code still use, and it is the one the orchestrating directive names
(Result.with_default, List.walk). Roc’s later renames are recorded below so that a reader who
knows today’s Roc can translate.
The rule for what a module holds is the same everywhere. A function belongs in the library when it
is typed precisely, never meets NA, and reports failure as a tag; that holds even when R has a
function with the same job (Str.trim beside trimws, List.walk beside Reduce), because the
point is a library that composes with match and the checker. What it does not take is machinery
that exists only for Roc’s type system or memory model.
Taken from Roc
Section titled “Taken from Roc”| Module | Functions |
|---|---|
Result |
is_ok, is_err, with_default, map, map_err, map_both, map2, and_then, on_err |
List |
len, is_empty, get, set, update, append, prepend, concat, single, repeat, reverse, join, map, map_with_index, map2, map3, map4, join_map, keep_if, drop_if, keep_oks, keep_errs, map_try, walk, walk_backwards, walk_until, walk_backwards_until, walk_with_index, walk_with_index_until, walk_from, walk_from_until, walk_try, for_each, for_each_try, any, all, contains, count_if, find_first, find_last, find_first_index, find_last_index, first, last, take_first, take_last, drop_first, drop_last, drop_at, sublist, split_at, split_first, split_last, split_on, split_on_list, chunks_of, intersperse, sort_with, sort_asc, sort_desc, swap, replace, append_if_ok, prepend_if_ok, concat_utf8, with_capacity, reserve, release_excess_capacity, range, keep_if_try, sum, product, min, max, starts_with, ends_with |
Str |
is_empty, concat, join_with, split_on, repeat, starts_with, ends_with, contains, trim, trim_start, trim_end, drop_prefix, drop_suffix, with_prefix, replace_each, replace_first, replace_last, split_first, split_last, count_utf8_bytes, to_utf8, from_utf8, from_utf8_lossy, with_ascii_lowercased, with_ascii_uppercased, caseless_ascii_equals, walk_utf8, walk_utf8_with_index, to_utf16, from_utf16, from_utf16_lossy, to_utf32, from_utf32, from_utf32_lossy |
Num |
to_str, compare, min, max, is_lt, is_gt, is_lte, is_gte, is_approx_eq, is_zero, is_positive, is_negative, is_even, is_odd, is_multiple_of, is_finite, is_infinite, abs, abs_diff, neg, ceiling, floor, round, pow, sin, cos, tan, atan, add_checked, sub_checked, mul_checked, add_saturated, sub_saturated, mul_saturated, div_checked, div_trunc_checked, div_ceil_checked, rem_checked, sqrt_checked, log_checked, add, sub, mul, div, div_trunc, div_ceil, rem, pow_int, sqrt, log, asin, acos, is_nan, bitwise_and, bitwise_or, bitwise_xor, bitwise_not, shift_left_by, shift_right_by, shift_right_zf_by, count_one_bits, count_leading_zero_bits, count_trailing_zero_bits, to_double, to_integer, to_integer_checked, pi, tau, e, infinity, max_integer, min_integer, max_double, min_double |
Bool |
true, false, and, or, not, is_eq, is_not_eq |
Dict |
empty, with_capacity, single, from_list, to_list, len, is_empty, get, contains, insert, remove, update, keys, values, walk, walk_until, map, join_map, keep_if, drop_if, insert_all, keep_shared, remove_all, reserve, capacity, release_excess_capacity, clear |
Set |
empty, with_capacity, single, from_list, to_list, len, is_empty, contains, insert, remove, union, intersection, difference, walk, walk_until, map, join_map, keep_if, drop_if, reserve, capacity, release_excess_capacity |
Added or changed on the way
Section titled “Added or changed on the way”Result.tryisResult.and_then. In R,tryalready means “catch an error”, and a reader who seesResult.try(r, f)would expect that.and_thenis the name Rust and Elm use for the same chaining. The module staysResultrather than Roc’s newerTryfor the same reason.- Positions are 1-based.
List.get(xs, 1L)is the first element, andmap_with_indexpasses 1-based indices, because every R reader expects that andxs[[1L]]agrees with it. A position is aninteger, and a whole-number double literal such as2is accepted where one is expected. - Every collection is a list.
Str.split_onreturnslist[character]andStr.join_withtakes one, so string functions compose withListwithout a conversion. List.from_vectorandList.to_vectorare new. R’s ownas.list()is typedfn(x: Any) -> list[Any], so it would throw away an element type.List.from_vector(c(1L, 2L))islist[integer], andList.to_vectorgoes back for an atomic element type.Str.to_integerandStr.to_doublereplace Roc’s per-type parsers (Str.toI64,Str.toF64), because R has exactly those two number types.Numchecked operations return a tag per failure::Overflowfor integer overflow and for a finite double result that overflows to infinity,:DivByZero,:SqrtOfNegative, and:LogNeedsPositive.Num.compareandList.sort_withuse:LT | :EQ | :GT.List.split_at(list, count)takes a count, sobeforeholds the firstcountelements andothersthe rest, which reads the same in 1-based and 0-based terms.Result.and_thenwidens the error: it returnsResult<B, E | F>, so a step may fail with a tag of its own, as Roc’sResult.tryallows through open tag unions.Num.ceiling,Num.floorandNum.roundkeep the number type. Roc’s return an integer, but R’sintegeris 32-bit, so a rounded double stays adoubleandNum.to_integer_checkedconverts it, returning:Err(:OutOfBounds)when it does not fit.roundrounds half away from zero, as Roc does, not half to even as R’srounddoes.Num.add_saturatedand its siblings clamp an integer to plus or minus 2147483647, because R’s smallest integer isNA. On doubles they are plain arithmetic.Num.pow,Num.sin,Num.cos,Num.tanandNum.atanreturn adouble, because R has no wider integer forpowto stay in.List.sort_ascandList.sort_desctake numbers only, as Roc’s do;List.sort_withsorts anything.- An unchecked operation stops with an R error where Roc crashes or returns
NaN.Num.addand its siblings stop on integer overflow and on aNaNresult, the divisions on a zero divisor,Num.sqrtandNum.logoutside their domain, andNum.asinandNum.acosoutside -1 to 1. Each error names the_checkedvariant that returns a tag instead.Num.is_nanis the one function that acceptsNaN. - Bit operations work on the 32-bit two’s complement and stop where the pattern is
NA. R’s smallest integer,-2147483648, isNA, so a result with that bit pattern (Num.bitwise_notof2147483647L,Num.shift_left_by(1L, 31L)) is an error. Every other pattern is an ordinary integer, which is why shifts can wrap as Roc’s do whileadd_wrapstays skipped. - Constants are values.
Bool.true,Bool.false,Num.pi,Num.tauandNum.eare bound inpackage:rylike the functions and declared in the stub as plain types (Num.pi : double). Underry R, each is an active binding that stops with the same explanation as a function. - The UTF-16 and UTF-32 conversions follow Roc’s newer
Strbuiltins and fail with:BadUtf16and:BadUtf32. A zero code unit or code point counts as bad, because an R string cannot hold NUL.
Readers who know Roc’s newer builtins will find fold, fold_until and fold_rev here as
walk, walk_until and walk_backwards, map_ok as map, ok_or as with_default, rev as
reverse, and the per-type plus_try family as Num.add_checked and its siblings.
Skipped
Section titled “Skipped”Box,Hash,Inspect,EncodeandDecode:Boxcontrols Roc’s memory layout, which ry’s heap decides by itself, and the others exist to serve Roc’s abilities, which ry does not have (traits are declined). R’sprint,formatandserializeplay the inspection and encoding roles.- The
Strcapacity functions: a string is an immutableCHARSXP, so there is no buffer to size. The capacity functions onList,DictandSetare taken, because updates happen in place (see “Running it”) and the heap never shrinks a resizable vector by itself. An empty dictionary or set has no key vector until its first key fixes the key type, soDict.with_capacityandSet.with_capacitysize its index, and the first insert sizes the key and value vectors to match. - Wrapping arithmetic (
add_wrap,sub_wrap,mul_wrap): R’s 32-bit integer reserves its smallest value forNA, so wrapping arithmetic would have to stop at that one value, and code that wants wrapping (hashing, checksums) wants it total. - Per-width functions and constants (
to_i8,to_u64_checked,min_i32,max_f64,int_cast,bytes_to_u16,f64_to_partsand the rest): R has exactly one integer and one double type, whichNum.to_integer,Num.to_integer_checkedandNum.to_doubleconvert between, and whose limits are the unsizedNum.max_integer,Num.min_integer,Num.max_doubleandNum.min_double.Num.to_fracisNum.to_double, andNum.nan_f64is skipped because no library function returnsNaN. Num.with_decimal_point,without_decimal_pointand theDecfunctions: R has no decimal type.- Effectful variants (
List.walk!,List.map!,Result.on_err!and the rest): R does not track effects, so the plain function already runs any effect, andList.for_eachandList.for_each_trycover the loops that exist only for their effects. Dict.is_eq,Set.is_eqand the otherEqimplementations:Bool.is_eqcompares any two values byidentical(), which already ignores a collection’s order.- An
Optionmodule: Roc has none, and returns aResultwith a descriptive error tag instead (:Err(:ListWasEmpty)). Code that wants an optional value writes:Some(x) | :Nonedirectly. Str.len: a string length counts bytes or code points, never what a reader means by characters, which is why Roc’s ownStr.lenonly points the reader at the alternatives;Str.count_utf8_bytesand R’sncharremain.
Values: R lists, R scalars and two new native kinds
Section titled “Values: R lists, R scalars and two new native kinds”The library takes R values where R has a good one, so it composes with existing code:
- A list is an R list.
List.map(xs, f)takes any R list, including one that holds records or tags, and returns a new list. An atomic vector is not a list, andList.from_vectorconverts one. - A string is a length-one
character, and a number is a length-oneintegerordouble.NAis refused with an error, because a ry function has noNAcase to fall into. - A result is a tag,
:Ok(value)or:Err(error). - Equality, wherever the library needs it (
List.contains,Dict.keep_shared), isidentical(), because ry has no equality ability to dispatch on.
R has no good dictionary or set: a named list allows duplicate names and matches them partially, and
an environment holds only string keys and has reference semantics. So Dict and Set are two new
native kinds beside records, tuples and tags, with value semantics like theirs: typeof() is
"dict" or "set", the implicit class is ry_dict or ry_set, and every R function that refuses
a record refuses them too, at run time and in the checker. They print as the call that rebuilds
them, identical() ignores insertion order (as Roc’s equality does), as.list() converts one to a
list of (key, value) tuples or of elements, and saveRDS() and C code refuse them like any
other ry value.
Keys and elements are logical, integer, double or character scalars, never NA or NaN.
The checker’s key constraint admits exactly those four, and the runtime refuses any other type
that reaches it through Unknown code. Keys compare exactly, -0 equals 0, and the integer 1L and the double 1 are the same
key, because R’s own == treats them as equal. A dictionary keeps insertion order, as Roc’s does,
so Dict.to_list and Dict.walk are deterministic.
Typing
Section titled “Typing”The signatures live in a shipped stub file, types/std.Rtypes, read as the stubs of a namespace
named std. That namespace is always active, like base, and the prelude puts its names in scope
in every file unless [check] prelude = false; hover names std as their home. Every signature is a rank-1 generic in the existing type grammar:
@alias Result<T, E> {:Ok(T) | :Err(E)}@type Dict<K, V>@type Set<T>
Result.map : <A, B, E> fn(result: Result<A, E>, f: fn(A) -> B) -> Result<B, E>List.walk : <T, S> fn(list: list[T], state: S, f: fn(S, T) -> S) -> SList.first : <T> fn(list: list[T]) -> Result<T, :ListWasEmpty>List.sum : <T: scalar numeric> fn(list: list[T]) -> TDict.get : <K: key, V> fn(dict: Dict<K, V>, key: K) -> Result<V, :KeyNotFound>Three additions to the checker make this possible:
@alias NAME<T, ...> {TYPE}in a stub declares a structural alias, exactly as#: @aliasdoes in a project, soResult<integer, character>works in every annotation. A project’s ownResultalias shadows the shipped one. The typing reference’s statement that no generic tag union is built in changes to nameResult.@type NAME<T, ...>in a stub declares a generic opaque type, soDict<character, double>is a type that only the library’s functions produce or read. The checker treats theDictandSetnominals as native values, sosum(dict)is atype-mismatchbefore it runs.- The rule that a record, tuple or tag passed to an R function is a
type-mismatchexempts functions therynamespace declares. The runtime marks the same functions on their builtin definitions, and a test compares the stub’s names with the runtime’s table, so the stub is the one list and nothing is exempt by spelling alone (List.mappis still an unknown name).
The checker needs no new inference: unifying :Ok(integer) | :Err(character) against
:Ok(A) | :Err(E) already binds both variables member by member.
Running it
Section titled “Running it”The functions are native Rust builtins in crates/interp/src/standard_library.rs, bound in
package:ry at startup, so they are always available and cost nothing beyond registering the
builtins. Higher-order functions call the R function they are given through the interpreter’s
ordinary call path, so a closure, a builtin and a function that uses match all work.
Errors are R errors that name the function. A wrong argument kind (List.map("a", f)) stops with a
message naming the parameter and the value it needs.
Dict and Set hash their keys into an open-addressing index stored in an integer vector next to
the keys, so a lookup is a hash and a probe, and the collector traces the index like any other
vector.
Updates happen in place when nothing else can observe the value, the way Roc and Koka reuse a
uniquely held value. For x <- f(x, ...) where f is flagged reusing_first_argument, the
evaluator checks that the binding being reassigned holds the value’s only reference and that no
other argument is the same value, and passes it as Call::reusable. A temporary with reference
count zero is reusable as well. A dictionary’s and a set’s key and value vectors are resizable and
double when full, so a loop of d <- Dict.insert(d, k, v) costs amortized constant time per
insert. Any other holder (a second binding, a list, a closure’s environment) makes the function
copy first, so value semantics never change.
Two extensions widen the x <- f(x, ...) form (decisions.md, “In-place updates through nested
targets”):
- A nested target,
state$seen <- Dict.insert(state$seen, k, v), reuses when the target is a chain of$or[[with a name, a string or a number, every list on the chain has one reference, and no other argument is the value or one of those lists. - Compiled code keeps values in registers that do not count as references. An environment that a
compiled body runs on is flagged, and an update from outside that body (
eval.parent(), a forced promise) reuses only a value no register holds.
A closure never receives the value for reuse. Moving the caller’s value into the parameter was
tried and removed: after the callee updates it in place, the caller’s variable no longer holds the
old value, and a second argument, a nested function, get() and an error unwinding through the
assignment can all read that variable.
Open questions
Section titled “Open questions”- A check-time diagnostic when a project that targets GNU R calls a library function, once a project can declare its runtime. A pure-R build for GNU R was declined: many functions return result tags, which GNU R cannot represent, and a partial build would split the library into two dialects.
- A helper that updates a collection it receives copies on every call, so
d <- helper(d, k)in a loop is quadratic. Reusing the value needs proof that nothing reads the caller’s variable until the assignment completes, which takes escape analysis of the callee and an undo path for errors.