Conversion Behavior Matrix
This document describes R-to-Rust (TryFromSexp) and Rust-to-R (IntoR) conversions. The coerce option extends input conversion; strict adds input and output checks for lossy integer types.
This document describes R-to-Rust (TryFromSexp) and Rust-to-R (IntoR) conversions. The coerce option extends input conversion; strict adds input and output checks for lossy integer types.
See also: miniextendr-api/src/from_r.rs, miniextendr-api/src/into_r.rs, miniextendr-api/src/strict.rs, miniextendr-api/src/coerce.rs
🔗Conversion Modes
🔗Normal Mode (default)
Native Rust types use their corresponding R storage: i32 accepts INTSXP, f64 accepts REALSXP, and bool accepts LGLSXP. The generated R precondition says the same (is.integer() for i32 / Vec<i32>, is.double() for f64 / Vec<f64>), so f(3) on an i32 parameter fails at the R boundary with “‘x’ must be integer” rather than inside Rust with “expected INTSXP, got REALSXP”. A non-optional numeric vector (Vec<i64>, Vec<f32>, …) rejects NA at its index; bind Vec<Option<T>> when the caller may pass NA. Non-native numeric types (i8, i16, u16, u32, i64, u64, isize, usize, f32) and their Vec<T> forms already accept integer, double, logical, and raw inputs through checked conversion. No attribute is needed.
🔗Coerce Mode (#[miniextendr(coerce)])
Coercion preserves every input type accepted in normal mode. Non-native numeric scalars and vectors use the same checked multi-source converters. The native i32 / Vec<i32> additionally accept whole-number doubles, logicals, and raws (3 reaches an i32 parameter as 3L; 3.5, NA, and out-of-range values are errors), and the native f64 / Vec<f64> additionally accept integers, logicals, and raws. NA follows as.integer() / as.numeric(): it propagates where the declared type can carry it (f64 and Vec<f64> receive NA_real_ for an integer or logical NA; Vec<i32> receives NA_integer_ for NA_real_ or a logical NA), while a scalar i32 keeps rejecting NA as it does without coerce. The generated R precondition follows the selected conversion, so x: i32 under coerce is gated by “integer or whole-number numeric” instead of failing later in Rust. Borrowed slices (&[i32], &[f64]) stay single-type because they cannot allocate a converted copy. bool and Vec<bool> additionally accept integer 0 and 1, while retaining logical inputs; NA and other integers are errors. Vector conversion reports all failing indices in one diagnostic.
The option can apply to a function, one parameter, or every function through coerce-default. no_coerce opts out of the feature default. Unmapped types, including Option<bool>, keep their ordinary conversion. When strict and coerce are both enabled, strict conversion takes precedence for the types it checks.
🔗Strict Mode (#[miniextendr(strict)])
Only INTSXP and REALSXP are accepted. RAWSXP and LGLSXP are rejected. Additionally, output values that don’t fit in R’s integer range (i32) cause a panic (R error) instead of silently widening to REALSXP (f64).
🔗R-to-Rust Conversions (Input: TryFromSexp)
🔗Native Scalar Types (Normal Mode)
These types require an exact R type match. Length must be 1.
| Rust Type | Accepted R Type | On NA | On Type Mismatch |
|---|---|---|---|
i32 | INTSXP | Error (SexpError::Na): use Option<i32> for NA | Error |
f64 | REALSXP | Returns NA_real_ (specific NaN) | Error |
u8 | RAWSXP | No NA concept in raw | Error |
Rcomplex | CPLXSXP | Returns Rcomplex { r: NA_real_, i: NA_real_ } | Error |
bool | LGLSXP | Error (NA is not true/false) | Error |
Rboolean | LGLSXP | Error (NA not representable) | Error |
RLogical | LGLSXP | Returns RLogical::Na | Error |
String | STRSXP | "" (lossy): use Option<String> for NA | Error |
&str | STRSXP | "" (lossy): use Option<&str> for NA | Error |
🔗Option Wrappers (Normal Mode)
Option<T> maps NA to None and NULL to None:
| Rust Type | Accepted R Type | On NA | On NULL |
|---|---|---|---|
Option<i32> | INTSXP | None | None |
Option<f64> | REALSXP | None | None |
Option<u8> | RAWSXP | Some(val) (raw has no NA) | None |
Option<Rcomplex> | CPLXSXP | None | None |
Option<bool> | LGLSXP | None | None |
Option<Rboolean> | LGLSXP | None | None |
Option<String> | STRSXP | None | None |
🔗Optional Containers, Data Frames and Either (NULL Only)
Option around a value with no NA of its own reads only NULL as None.
Any other value converts as the inner type and becomes Some, with the inner
conversion’s error (the R-side message prefixes the expectation with NULL or):
| Rust Type | On NULL | Otherwise | On failure |
|---|---|---|---|
Option<Vec<T>>, Option<HashMap<String, V>>, Option<BTreeMap<String, V>>, Option<HashSet<T>>, Option<BTreeSet<T>> | None | Some(..) of the inner conversion | The inner error |
Option<DataFrame> | None | Some(DataFrame): a view of the same object, so class, attributes, row names and NA cells are untouched | DataFrame’s error: 'x' must be NULL or a data frame: got numeric |
Option<Either<L, R>> (feature either) | None, before either arm is tried | Some(Either): L first, then R, exactly as Either<L, R> | Either‘s error, both arms’ reasons: 'x' must be NULL or a single double or a single string: got integer |
Neither DataFrame nor Either has an R-side type check, so neither optional
form has one. Under #[miniextendr(no_na)], Option<DataFrame> keeps the R
guard is.null(x) || !anyNA(x) and refuses a frame with an NA cell, as a
DataFrame parameter does. Option<Either<L, R>> has no R guard: as for
Either<L, R>, the C wrapper asks the arm the value converted to, so NULL
passes, a number arm refuses NA (and what a reading marker reads as NA),
and a data frame arm keeps its NA cells. A match_arg / choices
Option<Either<T, R>> decodes through the choice path instead
(ENUMS_AND_FACTORS.md).
🔗Coerced Scalar Types (Multi-Source)
These types accept INTSXP, REALSXP, RAWSXP, and LGLSXP:
| Rust Type | INTSXP | REALSXP | RAWSXP | LGLSXP | STRSXP |
|---|---|---|---|---|---|
i8 | Narrow i32->i8 | f64->i8 (reject frac/NaN) | u8->i8 | logical->i32->i8 | Error |
i16 | Narrow i32->i16 | f64->i16 (reject frac/NaN) | u8->i16 | logical->i32->i16 | Error |
u16 | i32->u16 (reject neg) | f64->u16 (reject frac/neg/NaN) | u8->u16 | logical->i32->u16 | Error |
u32 | i32->u32 (reject neg) | f64->u32 (reject frac/neg/NaN) | u8->u32 | logical->i32->u32 | Error |
f32 | i32 as f32 | f64 as f32 | u8 as f32 | logical as f32 | Error |
i64 | Widen i32->i64 | f64->i64 (reject frac/NaN/Inf) | u8->i64 | logical->i32->i64 | Error |
u64 | i32->u64 (reject neg) | f64->u64 (reject frac/neg/NaN) | u8->u64 | logical->i32->u64 | Error |
isize | Widen i32->isize | f64->i64->isize (reject frac) | u8->isize | logical->isize | Error |
usize | i32->usize (reject neg) | f64->u64->usize (reject frac/neg) | u8->usize | logical->i32->usize | Error |
Notes on coercion checks:
- Fractional check:
f64values with a non-zero fractional part are rejected (e.g.,3.14fails) - NaN/Inf: Both are rejected when converting
f64to integer types - Range check: Values outside the target type’s range are rejected (e.g., 300 fails for
i8) - NA propagation: NA_integer_ and NA_real_ produce errors for non-Option types;
Option<i64>etc. map NA toNone
🔗Strict Mode Scalar Types
Only INTSXP and REALSXP accepted; RAWSXP and LGLSXP are rejected:
| Rust Type | INTSXP | REALSXP | RAWSXP | LGLSXP |
|---|---|---|---|---|
i64 (strict) | Widen i32->i64 | f64->i64 (reject frac/NaN) | Panic | Panic |
u64 (strict) | i32->u64 (reject neg) | f64->u64 (reject frac/neg) | Panic | Panic |
isize (strict) | Delegates to i64 | Delegates to i64 | Panic | Panic |
usize (strict) | Delegates to u64 | Delegates to u64 | Panic | Panic |
🔗Vector Types
Vector conversions (Vec<T>) follow the same source-type rules as scalars:
| Rust Type | Accepted R Type(s) | Element Behavior |
|---|---|---|
Vec<i32> / &[i32] | INTSXP only | Direct memcpy |
Vec<f64> / &[f64] | REALSXP only | Direct memcpy |
Vec<u8> / &[u8] | RAWSXP only | Direct memcpy |
Vec<bool> | LGLSXP only | Each logical->bool; NA causes error |
Vec<String> | STRSXP only | Each CHARSXP->String; NA becomes "" (lossy) |
Vec<Option<i32>> | INTSXP only | NA_integer_ -> None |
Vec<Option<f64>> | REALSXP only | NA_real_ -> None |
Vec<Option<bool>> | LGLSXP only | NA_logical -> None |
Vec<Option<String>> | STRSXP only | NA_character_ -> None |
Vec<i64> (strict) | INTSXP or REALSXP | Per-element checked coercion; RAWSXP/LGLSXP rejected |
Vec<u64> (strict) | INTSXP or REALSXP | Per-element checked coercion; RAWSXP/LGLSXP rejected |
Vec<Option<i64>> (strict) | INTSXP or REALSXP | Same input-type gate as Vec<i64> (strict); NA -> None |
Vec<Option<u64>> (strict) | INTSXP or REALSXP | Same input-type gate as Vec<u64> (strict); NA -> None |
(A, B, ...) (arity 1–12) | VECSXP only | Positional (names ignored); exact length required; all failing elements reported in one batched error. Borrowed fields (&mut i32, &[f64], …) join the pre-conversion alias check, against each other and against other parameters; owned fields don’t |
Option<(A, B, ...)> | VECSXP or NULL | None ↔ NULL; Some uses the same positional list. |
Vec<(A, B, ...)>, Vec<Option<(A, B, ...)>> | VECSXP only | A list of positional lists; optional missing rows are NULL. Field and row errors are batched. Box<[(A, B, ...)]> and enclosing collections reuse these conversions. |
List / NamedList / ListMut | VECSXP only | A view of the argument list, no copy. A pairlist (formals(f), pairlist(...)) is refused with a type error, got pairlist, convert it with as.list(): R’s is.list() is TRUE for it, but a coerced copy would be a new object nothing roots (#1866). Call as.list() on the R side |
Option<List> / Option<NamedList> | VECSXP or NULL | NULL -> None; anything else as for List |
Vec<List> | VECSXP only | Each element must be a list (VECSXP; a pairlist is refused, not coerced); all failing elements reported in one batched error |
Vec<Option<List>> | VECSXP only | NULL element -> None; any other element as for Vec<List> |
🔗Parsing and Reading Markers
Argument markers that read a value the way an R function would (as.numeric(),
as.character()), or parse it from its text. Their IntoR hands back the inner
value (AsFromStr its Display text), which the marker reads back unchanged:
that is how a #[miniextendr] trait method takes a marker parameter through the
trait ABI. A function returning AsNumeric* / AsCharacter* gives the same R
value as returning .0; for these, returning .0 stays the idiom.
AsFromStr’s IntoR exists for the trait ABI: to return text, use
AsDisplay<T> / AsDisplayVec<T>.
| Rust Type | Accepted R Type(s) | Element Behavior | On failure |
|---|---|---|---|
AsNumericVec (.0: Vec<Option<f64>>) | REALSXP, INTSXP, LGLSXP, STRSXP, factor | Like as.numeric(): doubles as is (NaN stays Some(NaN)), integer/logical widened, character parsed with R’s R_strtod (surrounding blanks, Inf, NaN, 1e3, hex), factor read by its labels, never its codes. NA of any type, blank strings and the token "NA" → None | One InvalidValue: non-numeric value(s): "n/a", "<0.1" (elements 2, 5), 1-based, at most 10 listed then and N more. Other types: SexpError::Type |
AsNumeric (.0: Option<f64>) | Same, length 1 | Same | Same wording for the one value; wrong length: SexpError::Length |
AsCharacterVec (.0: Vec<Option<String>>) | Any atomic: LGLSXP, INTSXP, REALSXP, CPLXSXP, STRSXP, RAWSXP (incl. factor, Date, POSIXct) | Like as.character(), with R making the text: a plain vector through coerceVector() (0.1 + 0.2 → "0.3", 1e6 → "1e+06", TRUE → "TRUE", as.raw(255) → "ff"), a classed vector through its as.character() method (factor labels, formatted dates). NA of any type → None; "NA", "" and "NaN" stay strings. Names and dim dropped | Non-atomic input: SexpError::Type. A failing method: InvalidValue (as.character() failed: ...); a method returning non-character: InvalidValue |
AsCharacter (.0: Option<String>) | Same, length 1 | Same | Same; wrong length (of the argument or of the method’s result): SexpError::Length |
AsFromStrVec<T: FromStr> | STRSXP only | str::parse per element | One batched InvalidValue, 1-based: "n/a": <err> (element 2); NA is not allowed (element 3) |
AsFromStr<T: FromStr> | STRSXP, length 1 | str::parse | "n/a": <err>; NA_character_ → SexpError::Na |
The generated R precondition for AsNumeric / AsNumericVec is
is.numeric(x) || is.logical(x) || is.character(x) || is.factor(x) (“‘x’ must
be numeric, logical, character, or factor”; plus length 1 for AsNumeric, and
is.null(x) || under Option<…>). For AsCharacter / AsCharacterVec it is
is.atomic(x) (“‘x’ must be atomic”, base R’s own wording; plus length 1 for
AsCharacter), which refuses lists and data frames before .Call. AsFromStr /
AsFromStrVec have no R precondition; their errors come from the conversion.
Either way the argument error is the same condition (#1591); a conversion
failure reads 'dv' must be numeric: non-numeric value(s): "BLQ" (element 2)
(a single number for AsNumeric), with the Rust type in e$rust_type
(ERROR_HANDLING.md).
#[miniextendr(no_na)] on these four markers is also a post-conversion check:
the R guard !anyNA(x) runs first, then the C wrapper asks the converted value
and refuses what the marker read as None ("NA", blank strings, a factor
NA level, an NA from a class’s as.character()), plus NaN for
AsNumeric*. The refusal is the R guard’s condition: same classes, kind,
e$param and message, no e$rust_type, reported with the matched call.
🔗String-Parsed Types
AsFromStr<T> / AsFromStrVec<T> parse at the call site. To make a type of
your own convert directly, give it try_from_sexp_via_str_parse!(T, "label", |s| ...) (EXTENDING_MINIEXTENDR.md).
The built-in Uuid, Url, Regex, BigInt and BigUint conversions
(feature-gated) use it, so they read R input the same way:
| Rust Type | Accepted R Type | On NA / NULL | On parse failure |
|---|---|---|---|
T | STRSXP, length 1 | SexpError::Na | invalid <label>: <err> |
Option<T> | STRSXP, length 1, or NULL | None | invalid <label>: <err> |
Vec<T> | STRSXP | One batched InvalidValue: NA is not allowed (element 2) | Batched with the NAs, 1-based: invalid <label>: <err> (elements 3, 5), at most 10 listed then and N more |
Vec<Option<T>> | STRSXP | None | Batched as for Vec<T> |
🔗Newtypes (#[derive(TryFromSexp)])
A newtype over a type that converts reads as its inner type in each shape
whose inner container converts: Option<T> as Option<Inner>, Vec<T> as
Vec<Inner>, Vec<Option<T>> as Vec<Option<Inner>>, with the inner type’s
accepted R types, NA policy and errors. The containers never call the
newtype’s own try_from_sexp; #[try_from_sexp(validate = path)] adds a check
on the R value that every shape runs, to refuse what the inner type accepts:
| Rust Type | The check runs on | Error type |
|---|---|---|
T | the argument, before the inner conversion | the inner type’s; SexpError with validate |
Option<T> | any argument but NULL (None) | SexpError |
Vec<T>, Vec<Option<T>> over an atomic inner type (f64, String, …) | the whole vector, once (R keeps the class on the vector) | SexpError |
Vec<T> over List (a list of R objects) | each element, before it is read | SexpError |
Vec<Option<T>> over List | each element but NULL (None) | SexpError |
The inner container decides where the check runs, so a newtype of a newtype
over List checks each element too, outer check first. A list of objects
reports every failing element in one error, its position as R counts it:
invalid 'fits' argument: got a data frame (element 2); got no model object (elements 3, 5). A refusal with an argument message gives that message as
its element’s reason.
A check’s RError refusal keeps its classes and fields on the argument error
(EXTENDING_MINIEXTENDR.md).
In a list of objects, the error carries the classes of every refusal, and
each field name once, from the first refusal that has it.
🔗Rust-to-R Conversions (Output: IntoR)
🔗Scalar Types
| Rust Type | R Output Type | Notes |
|---|---|---|
i32 | INTSXP | Direct via Rf_ScalarInteger |
f64 | REALSXP | Direct via Rf_ScalarReal |
u8 | RAWSXP | Direct via Rf_ScalarRaw |
bool | LGLSXP | true->1, false->0 |
Rboolean | LGLSXP | Direct |
RLogical | LGLSXP | Includes NA support |
String / &str | STRSXP | UTF-8 encoding via Rf_mkCharLenCE |
char | STRSXP | Single UTF-8 character as string |
() | NILSXP | Returns R NULL |
PathBuf / OsString | STRSXP | Lossy UTF-8 conversion (to_string_lossy) |
🔗Widening Scalar Types
| Rust Type | R Output Type | Notes |
|---|---|---|
i8, i16, u16 | INTSXP | Infallible widening to i32 |
f32, u32 | REALSXP | Infallible widening to f64 |
🔗Smart Scalar Conversion (i64, u64, isize, usize)
These types use a smart conversion strategy: fit in i32 -> INTSXP, otherwise -> REALSXP.
| Rust Type | Condition | R Output Type | Notes |
|---|---|---|---|
i64 | i32::MIN < val <= i32::MAX | INTSXP | Exact representation |
i64 | Otherwise (incl. i32::MIN) | REALSXP | May lose precision >2^53 |
u64 | val <= i32::MAX | INTSXP | Exact representation |
u64 | val > i32::MAX | REALSXP | May lose precision >2^53 |
isize | Delegates to i64 | INTSXP or REALSXP | Same rules as i64 |
usize | Delegates to u64 | INTSXP or REALSXP | Same rules as u64 |
Why i32::MIN is excluded from INTSXP: In R, i32::MIN (-2147483648) is NA_integer_. Returning it as INTSXP would create an unintended NA value.
🔗Strict Output Conversion
With #[miniextendr(strict)], large integer types panic instead of falling back to REALSXP:
| Rust Type | Condition | Strict Behavior |
|---|---|---|
i64 | Fits in (i32::MIN, i32::MAX] | INTSXP (same as normal) |
i64 | Outside range | Panic (R error) |
u64 | val <= i32::MAX | INTSXP (same as normal) |
u64 | val > i32::MAX | Panic (R error) |
Vec<i64> | All elements fit | INTSXP vector |
Vec<i64> | Any element outside range | Panic (R error) |
Vec<Vec<i64>> / Vec<Option<Vec<i64>>> | Every element of every row fits | List of INTSXP vectors (None rows → NULL) |
Vec<Vec<i64>> / Vec<Option<Vec<i64>>> | Any element outside range | Panic (R error) naming each failing row (element N) and its inner position |
Option<Vec<i64>> | Some: every element fits / None | INTSXP vector / NULL; an out-of-range element panics like Vec<i64> |
The same rules apply to u64, isize and usize. The other list-of-vectors
shapes (Vec<&[i64]>, Vec<Box<[i64]>>, Vec<[i64; N]>,
Vec<Option<&[i64]>>) and Box<[i64]> have no strict path yet and widen as
in normal mode (#1684).
🔗The Absence Contract: What None Becomes in R
None does not map to one universal R value — it depends on the shape of
the return type, and the divergence is easy to trip over: changing a Rust
return type from Option<i32> to Option<&i32>, or from Option<i32> to
Option<Vec<i32>>, silently flips the R-visible absence value from
NA_integer_ to NULL; changing Option<f64> to Option<AsNumeric> (or
Option<String> to Option<AsCharacter>) flips NA to NULL the same way.
There is no compiler warning and no macro diagnostic. R code written as
is.na(x) against the old contract will error (“argument is of length zero”)
the moment it sees NULL instead.
| Rust Return Type | None becomes | Test with | Why |
|---|---|---|---|
Option<i32> / Option<f64> / Option<bool> / Option<Rboolean> / Option<RLogical> / Option<Rcomplex> / Option<String> / coerced scalars (i8, i16, u16, u32, f32, i64, u64, isize, usize) | NA_<type>_ | is.na(x) | Owned scalar — R has a native NA sentinel for each of these types |
Option<PathBuf> / Option<OsString> | NA_character_ | is.na(x) | Lossy-string family; follows the same convention as owned scalars |
Option<&str> | NA_character_ | is.na(x) | Exception to the Option<&T> row below: str is unsized, so it cannot use the generic Copy-bounded blanket impl. It has a hand-written impl instead that deliberately mirrors Option<String>. |
Option<&T> where T: Copy (e.g. Option<&i32>, Option<&f64>, Option<&bool>) | NULL | is.null(x) | A borrowed reference has nothing to copy on None — there is no NA representation for “no reference” |
Option<Vec<T>> / Option<Box<[T]>> (any T with Vec<T>: IntoR) / Option<[T; N]> (any T with &[T]: IntoR) / Option<HashMap<String, V>> / Option<BTreeMap<String, V>> / Option<HashSet<T>> / Option<BTreeSet<T>> | NULL | is.null(x) | No container type has a native R NA sentinel |
Option<AsNumeric> / Option<AsNumericVec> / Option<AsCharacter> / Option<AsCharacterVec> | NULL | is.null(x) | NA is already Some(marker(None)); NULL is what the marker’s Option reads as None. So switching a return from Option<f64> to Option<AsNumeric> changes NA to NULL |
Option<&[T]> | NULL | is.null(x) | Some converts as the slice does |
Option<ExternalPtr<T>> / a method’s Option<Self> | NULL | is.null(x) | Some is the external pointer, which a method’s R wrapper wraps in its class. This is how a class is returned optionally: no IntoR for Option<Class> can exist (the orphan rule), so Option<Class> doesn’t compile, and an explicit class wrap (Option<WrapAsR6<Class>>, wrap = "r6") is the other spelling |
Nullable<T> (any T: IntoR) | NULL | is.null(x) | Not an Option: a return type for “no value at all” whatever T is. Some(v) converts as v, so Nullable<i32> gives an integer or NULL where Option<i32> gives NA_integer_. RIterator::next / nth return it, so the end of iteration can’t be mistaken for an NA item |
Option<()> / Option<SEXP> | Not a value at all — None raises a tagged rust_* R condition | tryCatch(f(), error = \(e) ...) | The macro special-cases these shapes as an error boundary rather than an absence value — see Result and Error Types below for the analogous Result behavior |
One rule covers every #[miniextendr] return: a standalone function, an
inherent method in any class system and a trait method convert an Option<T>
the same way, through IntoR for Option<T> (#1415, #2088). Some of an empty
container stays an empty vector/list. A method’s own Option<Self> (a lookup
constructor, or a fallible step such as self -> Option<Self> /
&mut self -> Option<&mut Self>) follows the same rule: None is NULL.
A method whose value the R wrapper wraps in a class (Option<Vec<Class>>,
Option<Vec<ExternalPtr<Class>>>, Option<Vec<Self>>, an explicit
WrapAs* / wrap return) gives NULL for None: the class wrap lets NULL
through rather than constructing from it (or turning it into list()).
Option<Class> with no explicit wrap has no conversion: a class can’t have
IntoR for Option<Class> (the orphan rule refuses it in the class’s crate).
Under an explicit wrap (Option<WrapAsR6<Class>>, wrap = "r6") the macro
converts the payload itself, and None is NULL.
See also COLUMNAR_OPTION_NONE.md for how an
all-None Option<T> column in a DataFrameRow/columnar context (as
opposed to a bare scalar return covered above) is downgraded to a typed NA
vector rather than a list(NULL, NULL, ...).
🔗Vector Types
| Rust Type | R Output Type | Notes |
|---|---|---|
Vec<i32> / &[i32] | INTSXP | Bulk memcpy |
Vec<f64> / &[f64] | REALSXP | Bulk memcpy |
Vec<u8> / &[u8] | RAWSXP | Bulk memcpy |
Vec<bool> / &[bool] | LGLSXP | Element-wise bool as i32 |
Vec<String> | STRSXP | Element-wise CHARSXP creation |
Vec<Option<i32>> | INTSXP | None -> NA_integer_ |
Vec<Option<f64>> | REALSXP | None -> NA_real_ |
Vec<Option<bool>> | LGLSXP | None -> NA_logical |
Vec<Option<String>> | STRSXP | None -> NA_character_ |
Vec<Option<&str>> | STRSXP | None -> NA_character_ (borrowed strings, mirrors Vec<Option<String>>) |
Vec<Option<scalar>> lands as a typed R vector with NA sentinels. Vec<Option<C>> for collection element types lands as a list-column with NULL for None (see Collection Types below).
Fixed-size array returns [T; N] delegate to &[T]: native, smart-integer,
coerced numeric, String, &str, bool and Rboolean elements keep their
slice representation. No intermediate Vec is allocated, and fallible
conversions keep the slice’s error type. N = 0 produces a typed empty vector.
Direct IntoR conversion of Option<[T; N]> maps None to NULL.
🔗Smart Vector Conversion (Vec of large integers)
Vec<i64>, Vec<u64>, Vec<u32>, Vec<isize>, Vec<usize> (and the matching
slices &[i64] … &[usize]) check whether all elements fit in i32. If yes, the
entire vector is INTSXP; otherwise, the entire vector is REALSXP. A list of such
vectors (Vec<Vec<usize>>, a DataFrameRow list column) makes the choice per
row: one row with a value past i32::MAX is a double vector while the other rows
stay integer, and an empty row is integer(0).
| Rust Type | All Fit in i32? | R Output Type |
|---|---|---|
Vec<i64> | Yes (all in (i32::MIN, i32::MAX]) | INTSXP |
Vec<i64> | No (any element outside) | REALSXP |
Vec<u64> | Yes (all <= i32::MAX) | INTSXP |
Vec<u64> | No | REALSXP |
Vec<u32> | Yes (all <= i32::MAX) | INTSXP |
Vec<u32> | No | REALSXP |
🔗Collection Types
| Rust Type | R Output Type |
|---|---|
HashMap<String, V> | Named list (VECSXP) |
BTreeMap<String, V> | Named list (VECSXP) |
HashSet<T> / BTreeSet<T> | Vector (order may vary for HashSet) |
VecDeque<T> | Vector (converted to Vec first) |
BinaryHeap<T> | Vector (arbitrary order) |
Vec<Vec<T>> (any T with Vec<T>: IntoR) | List of vectors (VECSXP); each row is exactly what Vec<T> produces |
Vec<Box<[T]>> / Vec<[T; N]> (any T with Vec<T>: IntoR) | List of vectors (VECSXP), as Vec<Vec<T>> |
Vec<&[T]> (any T with &[T]: IntoR) | List of vectors (VECSXP), borrowed slices copied into fresh vectors |
(A, B, ...) | Unnamed list (VECSXP), arity 1–12; round-trips via TryFromSexp (positional, names ignored) |
Option<(A, B, ...)> | The positional list for Some, NULL for None |
Vec<(A, B, ...)> / Vec<Option<(A, B, ...)>> | List of positional lists; missing optional rows are NULL |
Option<List> / Option<ListMut> | Some(list) → the list (VECSXP), None → NULL; the inverse of their TryFromSexp impls |
Vec<List> / Vec<Option<List>> | List of the lists (VECSXP), None → NULL; the lists must be rooted by the caller (arguments are), since the result is allocated first. A #[derive(IntoR)] newtype over List returns its Vec / Vec<Option> the same way |
Tuple try_into_sexp conversions return IntoRError::Inner when fields fail,
with all failing field positions collected in one diagnostic. Option<Tuple>,
Vec<Tuple>, Vec<Option<Tuple>> and boxed tuple rows preserve these errors;
row containers also collect failures across rows. Successful fields and rows
enter the protected list before the next conversion runs.
Tuples with 1–12 fields also implement IntoList and TryFromList, using
these same positional conversions and batched field errors. Element-wise
Coerce covers all twelve arities, including the singleton; the existing
blanket supplies its infallible TryCoerce. () remains R NULL. Fields that
implement Into<RValue> also give the tuple an owned unnamed-list conversion
for condition data. Heterogeneous tuples have no single native atomic layout
or missing-value sentinel, so they do not implement RNativeType or IsNA.
🔗Vec<Option<C>> for collection element types
Vec<Option<C>> where C is a collection lands as a VECSXP list-column. Some(c) becomes the element’s normal IntoR output; None becomes R_NilValue (NULL). The wrap is required by enum DataFrameRow align codegen, which represents every column as Vec<Option<T>> so non-payload variants can NA-fill.
| Rust Type | R Output Type | None Behavior |
|---|---|---|
Vec<Option<Vec<T>>> / Vec<Option<Box<[T]>>> (any T with Vec<T>: IntoR) | VECSXP of the vectors Vec<T> produces | NULL |
Vec<Option<HashSet<T>>> (T: RNativeType + Eq + Hash) | VECSXP of typed vectors | NULL |
Vec<Option<HashSet<String>>> | VECSXP of character vectors | NULL |
Vec<Option<BTreeSet<T>>> (T: RNativeType + Ord) | VECSXP of sorted typed vectors | NULL |
Vec<Option<BTreeSet<String>>> | VECSXP of sorted character vectors | NULL |
Vec<Option<HashMap<String, V>>> (V: IntoR) | VECSXP of named lists | NULL |
Vec<Option<BTreeMap<String, V>>> (V: IntoR) | VECSXP of named lists | NULL |
Vec<Option<&[T]>> (any T with &[T]: IntoR) | VECSXP of the vectors &[T] produces | NULL |
Vec<Option<Vec<K>>> (keys column) | VECSXP of typed vectors | NULL |
Vec<Option<Vec<V>>> (V: IntoR, values column) | VECSXP | NULL |
The
Vec<Option<Vec<K>>>andVec<Option<Vec<V>>>types appear as the companion-struct column types forHashMap<K,V>/BTreeMap<K,V>enum variant fields, expanded to<field>_keys/<field>_valuescolumns byDataFrameRowderive. No newIntoRimpls are required beyond those already present.
🔗Nested DataFrameRow enum fields
When an enum variant field is itself a DataFrameRow enum, it flattens into prefixed columns at into_dataframe() time. The companion struct holds Vec<Option<Inner>> and calls Inner::to_dataframe(dense_rows) → into_named_columns() → scatter to full-length column via scatter_column, which keeps each column’s attributes (a factor column of the inner type stays a factor, NA in absent-variant rows).
| Mode | Rust field annotation | Companion struct type | R column(s) | NA behavior |
|---|---|---|---|---|
| Flatten (default) | (none) — inner must impl DataFrameRow | Vec<Option<Inner>> | <field>_variant (STRSXP) + all of Inner’s other columns, each prefixed with <field>_ | NA in all prefixed columns for absent-variant rows |
as_factor | #[dataframe(as_factor)] — inner must be unit-only (impl UnitEnumFactor) | Vec<Option<Inner>> | <field> (INTSXP factor) | NA_integer_ for absent-variant rows |
as_list | #[dataframe(as_list)] | Vec<Option<Inner>> | <field> (VECSXP list-column) | NULL for absent-variant rows |
Notes:
- Factor levels: emitted in enum variant declaration order;
levels(df$field)returns all variants regardless of which appear in the data. - Inner tag: use
#[dataframe(tag = "variant")]on the inner enum so the discriminant column is<outer_field>_variant(single underscore). A leading underscore on the inner tag (e.g.tag = "_variant") produces a double underscore in the outer column name. - Auto-emit
UnitEnumFactor:#[derive(DataFrameRow)]on a unit-only enum also emitsUnitEnumFactor+IntoR(factor SEXP) automatically. The derive rejects enums with type or const parameters, so a generic unit enum must implementUnitEnumFactorby hand if needed. - Struct fields: struct-typed variant fields that implement
DataFrameRowflatten the same way.as_listkeeps one as a list-column, which needs the struct to deriveIntoListtoo;as_factorapplies only to unit-only enums.
🔗Result and Error Types
This is the Result half of the absence contract — Err follows a
completely different rule depending on the error type and the
unwrap_in_r attribute:
| Rust Type | Ok(val) | Err(e) |
|---|---|---|
Result<T, E> (default, E != ()) | T::into_sexp() | Not a value — raises a tagged rust_* R condition (E: Debug-formatted message); catch with tryCatch |
Result<(), E> (default, E != ()) | NULL, invisibly | Same as above — raises |
Result<SEXP, E> (default, E != ()) | The SEXP directly | Same as above — raises |
Result<T, E: Display> (#[miniextendr(unwrap_in_r)], E != ()) | T::into_sexp() | list(error = e.to_string()) — not NULL, not NA; test with !is.null(x$error) |
Result<T, ()> (any T, with or without unwrap_in_r) | T::into_sexp() | NULL (R_NilValue); test with is.null(x) |
Result<(), ()> | NULL, invisibly | NULL, invisibly (indistinguishable from Ok) |
Result<T, ()> is the only Result shape whose Err reaches R as a
plain value — the macro rewrites Err(()) to Err(NullOnErr)
(miniextendr_api::into_r::NullOnErr) specifically because a unit error
carries no message worth reporting. Every other E follows the default
error-boundary path (tagged-condition transport is the framework’s only
error path, see miniextendr-api/CLAUDE.md) unless unwrap_in_r is set,
in which case Err becomes data (list(error = ...)) instead of a raised
condition.
Audit note: rpkg/tests/testthat/test-conversions.R:290
(expect_true(is.null(conv_result_i32_err()))) exercises the
Result<i32, ()> shape specifically. It is easy to over-generalize this
into “Result::Err becomes NULL” — that only holds for the () error
type. Any other error type raises instead of returning a value.
🔗Intentional Asymmetries (argument-only / return-only)
Some types deliberately support only one direction. These are design decisions, not gaps:
| Rust Type | Direction | Why |
|---|---|---|
Option<u8> / Vec<Option<u8>> | TryFromSexp only (no IntoR) | RAWSXP has no NA sentinel; None has no faithful R representation. Inbound, R raw vectors never contain NA so Option<u8> is always Some. Return Vec<u8> instead. |
Regex (regex feature) | TryFromSexp only | Accept a pattern string from R as a compiled regex argument. A compiled regex has no useful R value; return the pattern String if needed. |
AhoCorasick (aho-corasick feature) | TryFromSexp only | Same shape: built from an R character vector of patterns; no meaningful outbound form. |
By contrast, Url and Uuid round-trip: both directions are implemented
because the R representation (a string) is faithful in both directions. The
reading markers round-trip too: AsNumeric* / AsCharacter* convert to R as
their inner value (NA for None, NULL for an Option<marker> that is
None), and AsFromStr<T> / AsFromStrVec<T> as their Display text, which
reads back unchanged when T::from_str(&t.to_string()) == t.
🔗Date / Time Conversions (Feature-Gated)
🔗time feature
Enabled with features = ["time"].
| Rust Type | R Type | Notes |
|---|---|---|
OffsetDateTime | POSIXct | UTC only; tzone attr set to "UTC" |
Date | Date | Days since 1970-01-01 |
Duration | difftime | Seconds unit |
🔗jiff feature
Enabled with features = ["jiff"]. Bundles IANA timezone database (tzdb-bundle-always).
| Rust Type | R Type | Notes |
|---|---|---|
Timestamp | POSIXct (UTC) | Nanosecond precision; floor-based fractional-second split for correctness on negative timestamps |
Zoned | POSIXct + tzone attr | IANA timezone name preserved; unknown tz on input → error (no UTC fallback) |
civil::Date | Date | Days since 1970-01-01 via Span::try_days |
SignedDuration | difftime (secs) | Nanosecond-precision duration stored as f64 seconds |
Vec<Timestamp> (ALTREP) | POSIXct | JiffTimestampVec; elements materialized on access via Arc<Vec<Timestamp>> |
Vec<Zoned> (ALTREP, single-tz strict) | POSIXct + tzone attr | JiffZonedVec; construction-time check rejects heterogeneous timezones |
Adapter traits (wrapping via ExternalPtr):
RTimestamp, RDate, RZoned, RSignedDuration, RSpan, RDateTime, RTime
vctrs rcrd constructors (requires vctrs feature):
span_vec_to_rcrd, zoned_vec_to_rcrd, datetime_vec_to_rcrd, time_vec_to_rcrd
🔗Raw/Bytemuck Conversions (Feature-Gated)
Enabled with features = ["raw_conversions"]. Uses R’s RAWSXP for binary POD data.
| Wrapper | Direction | Format | Type Tag |
|---|---|---|---|
Raw<T> | Both | Headerless bytes | No |
RawSlice<T> | Both | Headerless byte sequence | No |
RawTagged<T> | Both | 16-byte header + bytes | Yes (mx_raw_type attr) |
RawSliceTagged<T> | Both | 16-byte header + byte sequence | Yes (mx_raw_type attr) |
Safety checks: length validation, alignment (copy if misaligned), magic/version validation (tagged only), type name matching (tagged only).
🔗ndarray Conversions (Feature-Gated)
Enabled with features = ["ndarray"]. Shape maps to R’s dim attribute with
column-major (Fortran) element order in both directions, independent of the
array’s memory layout. Array1 converts to/from a plain vector (no dim);
Array2 additionally accepts a plain vector as an n x 1 column.
| Rust Element Type | R Type | NA Handling | Notes |
|---|---|---|---|
i32, f64, u8, RLogical, Rcomplex | Typed array/matrix | Passes through as the sentinel value | Contiguous copy via RNativeType blanket impls |
i8, i16, i64, u16, u32, u64, isize, usize, f32, bool | Typed array/matrix (input only) | Error on unrepresentable values | Element-wise TryCoerce from the R native type |
String | Character array/matrix (STRSXP) | Inbound NA becomes "" (lossy, mirrors Vec<String>); never produces NA outbound | Explicit element-wise impls (STRSXP is not contiguous) |
Option<String> | Character array/matrix (STRSXP) | None <-> NA_character_ | Explicit element-wise impls; preferred for NA-carrying data |
Owned arrays (Array0..Array6, ArrayD) convert in both directions;
views (ArrayView*) convert Rust-to-R only (numeric element types).
🔗Special Values Quick Reference
| R Value | Rust Representation | Notes |
|---|---|---|
NA_integer_ | i32::MIN (-2147483648) | Excluded from valid i32 range; inbound NA produces SexpError::Na on i32 (use Option<i32> to receive NA) |
NA_real_ | 0x7FF00000000007A2 (a NaN whose low word is 1954) | Every NA check (inbound conversions, serde, RValue, SexpExt::as_real, Arrow nulls, complex NA, ALTREP sum/min/max) follows R’s R_IsNA (low word 1954), so computed NAs such as NA_real_ * 1 (bits 0x7FF80000000007A2) read as NA too. ALTREP no_na is R’s ISNAN hint: a NaN counts as missing there, because anyNA() trusts it |
NA_logical_ | i32::MIN | Same sentinel as NA_integer_ |
NA_character_ | R_NaString CHARSXP | Mapped to None in Option<String> |
NaN | f64::NAN | Not the same as NA_real_; passes through as valid f64 |
Inf / -Inf | f64::INFINITY / f64::NEG_INFINITY | Valid f64 values; rejected when coercing to integers |
NULL | R_NilValue | Mapped to None in Option<T>; () produces NULL |
🔗Cookbook: Common Conversion Recipes
🔗Vec<Option<i64>>: how it converts to R
Each element uses the smart i64 conversion. If all Some values fit in i32, the whole vector is INTSXP; otherwise REALSXP. None values become NA_integer_ or NA_real_ accordingly.
#[miniextendr]
fn make_nullable_ids() -> Vec<Option<i64>> {
vec![Some(1), None, Some(42), Some(i64::MAX)]
// -> REALSXP because i64::MAX doesn't fit in i32
}🔗“I want to accept either integer or numeric from R”
Use a coerced type (i64, u64, f32), which accepts INTSXP, REALSXP, RAWSXP, and LGLSXP automatically:
#[miniextendr]
fn flexible_input(x: i64) -> i64 {
x * 2 // works with integer(1) or numeric(1) from R
}
Or use #[miniextendr(strict)] to only accept INTSXP and REALSXP (no raw/logical):
#[miniextendr(strict)]
fn strict_input(x: i64) -> i64 { x * 2 }🔗“I want a named list from R as a HashMap”
use std::collections::HashMap;
#[miniextendr]
fn process_config(config: HashMap<String, f64>) -> f64 {
config.get("threshold").copied().unwrap_or(0.5)
}
In R: process_config(list(threshold = 0.9, alpha = 0.05))
🔗“I want to return NA for missing values”
Wrap in Option. None becomes the appropriate NA:
#[miniextendr]
fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}🔗“I want to return NULL on failure, not an error”
Use Result<T, ()>:
#[miniextendr]
fn try_parse(s: String) -> Result<i32, ()> {
s.parse::<i32>().map_err(|_| ())
// Ok(42) -> 42L in R; Err(()) -> NULL in R
}🔗“I have a struct and want to pass it to R and back”
Derive ExternalPtr on the struct and put #[miniextendr] on its impl block.
The struct is wrapped in an ExternalPtr:
use miniextendr_api::{ExternalPtr, miniextendr};
#[derive(ExternalPtr)]
struct Counter { n: i32 }
#[miniextendr]
impl Counter {
fn new() -> Self { Counter { n: 0 } }
fn increment(&mut self) { self.n += 1; }
fn get(&self) -> i32 { self.n }
}🔗“I want to accept R’s ... (dots)”
Take one &Dots parameter, at any position (one per function). A formal after it is matched by name only in R:
#[miniextendr]
fn count_all(x: f64, rest: &Dots, #[miniextendr(default = "FALSE")] verbose: bool) -> isize {
// R: count_all(x, ..., verbose = FALSE); `rest` holds the extras as a list
rest.len()
}
For typed dots validation, see DOTS_TYPED_LIST.md.