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 TypeAccepted R TypeOn NAOn Type Mismatch
i32INTSXPError (SexpError::Na): use Option<i32> for NAError
f64REALSXPReturns NA_real_ (specific NaN)Error
u8RAWSXPNo NA concept in rawError
RcomplexCPLXSXPReturns Rcomplex { r: NA_real_, i: NA_real_ }Error
boolLGLSXPError (NA is not true/false)Error
RbooleanLGLSXPError (NA not representable)Error
RLogicalLGLSXPReturns RLogical::NaError
StringSTRSXP"" (lossy): use Option<String> for NAError
&strSTRSXP"" (lossy): use Option<&str> for NAError

🔗Option Wrappers (Normal Mode)

Option<T> maps NA to None and NULL to None:

Rust TypeAccepted R TypeOn NAOn NULL
Option<i32>INTSXPNoneNone
Option<f64>REALSXPNoneNone
Option<u8>RAWSXPSome(val) (raw has no NA)None
Option<Rcomplex>CPLXSXPNoneNone
Option<bool>LGLSXPNoneNone
Option<Rboolean>LGLSXPNoneNone
Option<String>STRSXPNoneNone

🔗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 TypeOn NULLOtherwiseOn failure
Option<Vec<T>>, Option<HashMap<String, V>>, Option<BTreeMap<String, V>>, Option<HashSet<T>>, Option<BTreeSet<T>>NoneSome(..) of the inner conversionThe inner error
Option<DataFrame>NoneSome(DataFrame): a view of the same object, so class, attributes, row names and NA cells are untouchedDataFrame’s error: 'x' must be NULL or a data frame: got numeric
Option<Either<L, R>> (feature either)None, before either arm is triedSome(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 TypeINTSXPREALSXPRAWSXPLGLSXPSTRSXP
i8Narrow i32->i8f64->i8 (reject frac/NaN)u8->i8logical->i32->i8Error
i16Narrow i32->i16f64->i16 (reject frac/NaN)u8->i16logical->i32->i16Error
u16i32->u16 (reject neg)f64->u16 (reject frac/neg/NaN)u8->u16logical->i32->u16Error
u32i32->u32 (reject neg)f64->u32 (reject frac/neg/NaN)u8->u32logical->i32->u32Error
f32i32 as f32f64 as f32u8 as f32logical as f32Error
i64Widen i32->i64f64->i64 (reject frac/NaN/Inf)u8->i64logical->i32->i64Error
u64i32->u64 (reject neg)f64->u64 (reject frac/neg/NaN)u8->u64logical->i32->u64Error
isizeWiden i32->isizef64->i64->isize (reject frac)u8->isizelogical->isizeError
usizei32->usize (reject neg)f64->u64->usize (reject frac/neg)u8->usizelogical->i32->usizeError

Notes on coercion checks:

  • Fractional check: f64 values with a non-zero fractional part are rejected (e.g., 3.14 fails)
  • NaN/Inf: Both are rejected when converting f64 to 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 to None

🔗Strict Mode Scalar Types

Only INTSXP and REALSXP accepted; RAWSXP and LGLSXP are rejected:

Rust TypeINTSXPREALSXPRAWSXPLGLSXP
i64 (strict)Widen i32->i64f64->i64 (reject frac/NaN)PanicPanic
u64 (strict)i32->u64 (reject neg)f64->u64 (reject frac/neg)PanicPanic
isize (strict)Delegates to i64Delegates to i64PanicPanic
usize (strict)Delegates to u64Delegates to u64PanicPanic

🔗Vector Types

Vector conversions (Vec<T>) follow the same source-type rules as scalars:

Rust TypeAccepted R Type(s)Element Behavior
Vec<i32> / &[i32]INTSXP onlyDirect memcpy
Vec<f64> / &[f64]REALSXP onlyDirect memcpy
Vec<u8> / &[u8]RAWSXP onlyDirect memcpy
Vec<bool>LGLSXP onlyEach logical->bool; NA causes error
Vec<String>STRSXP onlyEach CHARSXP->String; NA becomes "" (lossy)
Vec<Option<i32>>INTSXP onlyNA_integer_ -> None
Vec<Option<f64>>REALSXP onlyNA_real_ -> None
Vec<Option<bool>>LGLSXP onlyNA_logical -> None
Vec<Option<String>>STRSXP onlyNA_character_ -> None
Vec<i64> (strict)INTSXP or REALSXPPer-element checked coercion; RAWSXP/LGLSXP rejected
Vec<u64> (strict)INTSXP or REALSXPPer-element checked coercion; RAWSXP/LGLSXP rejected
Vec<Option<i64>> (strict)INTSXP or REALSXPSame input-type gate as Vec<i64> (strict); NA -> None
Vec<Option<u64>> (strict)INTSXP or REALSXPSame input-type gate as Vec<u64> (strict); NA -> None
(A, B, ...) (arity 1–12)VECSXP onlyPositional (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 NULLNone ↔ NULL; Some uses the same positional list.
Vec<(A, B, ...)>, Vec<Option<(A, B, ...)>>VECSXP onlyA 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 / ListMutVECSXP onlyA 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 NULLNULL -> None; anything else as for List
Vec<List>VECSXP onlyEach element must be a list (VECSXP; a pairlist is refused, not coerced); all failing elements reported in one batched error
Vec<Option<List>>VECSXP onlyNULL 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 TypeAccepted R Type(s)Element BehaviorOn failure
AsNumericVec (.0: Vec<Option<f64>>)REALSXP, INTSXP, LGLSXP, STRSXP, factorLike 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" → NoneOne 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 1SameSame 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 droppedNon-atomic input: SexpError::Type. A failing method: InvalidValue (as.character() failed: ...); a method returning non-character: InvalidValue
AsCharacter (.0: Option<String>)Same, length 1SameSame; wrong length (of the argument or of the method’s result): SexpError::Length
AsFromStrVec<T: FromStr>STRSXP onlystr::parse per elementOne batched InvalidValue, 1-based: "n/a": <err> (element 2); NA is not allowed (element 3)
AsFromStr<T: FromStr>STRSXP, length 1str::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 TypeAccepted R TypeOn NA / NULLOn parse failure
TSTRSXP, length 1SexpError::Nainvalid <label>: <err>
Option<T>STRSXP, length 1, or NULLNoneinvalid <label>: <err>
Vec<T>STRSXPOne 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>>STRSXPNoneBatched 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 TypeThe check runs onError type
Tthe argument, before the inner conversionthe 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 readSexpError
Vec<Option<T>> over Listeach 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 TypeR Output TypeNotes
i32INTSXPDirect via Rf_ScalarInteger
f64REALSXPDirect via Rf_ScalarReal
u8RAWSXPDirect via Rf_ScalarRaw
boolLGLSXPtrue->1, false->0
RbooleanLGLSXPDirect
RLogicalLGLSXPIncludes NA support
String / &strSTRSXPUTF-8 encoding via Rf_mkCharLenCE
charSTRSXPSingle UTF-8 character as string
()NILSXPReturns R NULL
PathBuf / OsStringSTRSXPLossy UTF-8 conversion (to_string_lossy)

🔗Widening Scalar Types

Rust TypeR Output TypeNotes
i8, i16, u16INTSXPInfallible widening to i32
f32, u32REALSXPInfallible widening to f64

🔗Smart Scalar Conversion (i64, u64, isize, usize)

These types use a smart conversion strategy: fit in i32 -> INTSXP, otherwise -> REALSXP.

Rust TypeConditionR Output TypeNotes
i64i32::MIN < val <= i32::MAXINTSXPExact representation
i64Otherwise (incl. i32::MIN)REALSXPMay lose precision >2^53
u64val <= i32::MAXINTSXPExact representation
u64val > i32::MAXREALSXPMay lose precision >2^53
isizeDelegates to i64INTSXP or REALSXPSame rules as i64
usizeDelegates to u64INTSXP or REALSXPSame 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 TypeConditionStrict Behavior
i64Fits in (i32::MIN, i32::MAX]INTSXP (same as normal)
i64Outside rangePanic (R error)
u64val <= i32::MAXINTSXP (same as normal)
u64val > i32::MAXPanic (R error)
Vec<i64>All elements fitINTSXP vector
Vec<i64>Any element outside rangePanic (R error)
Vec<Vec<i64>> / Vec<Option<Vec<i64>>>Every element of every row fitsList of INTSXP vectors (None rows → NULL)
Vec<Vec<i64>> / Vec<Option<Vec<i64>>>Any element outside rangePanic (R error) naming each failing row (element N) and its inner position
Option<Vec<i64>>Some: every element fits / NoneINTSXP 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 TypeNone becomesTest withWhy
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>)NULLis.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>>NULLis.null(x)No container type has a native R NA sentinel
Option<AsNumeric> / Option<AsNumericVec> / Option<AsCharacter> / Option<AsCharacterVec>NULLis.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]>NULLis.null(x)Some converts as the slice does
Option<ExternalPtr<T>> / a method’s Option<Self>NULLis.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)NULLis.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 conditiontryCatch(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 TypeR Output TypeNotes
Vec<i32> / &[i32]INTSXPBulk memcpy
Vec<f64> / &[f64]REALSXPBulk memcpy
Vec<u8> / &[u8]RAWSXPBulk memcpy
Vec<bool> / &[bool]LGLSXPElement-wise bool as i32
Vec<String>STRSXPElement-wise CHARSXP creation
Vec<Option<i32>>INTSXPNone -> NA_integer_
Vec<Option<f64>>REALSXPNone -> NA_real_
Vec<Option<bool>>LGLSXPNone -> NA_logical
Vec<Option<String>>STRSXPNone -> NA_character_
Vec<Option<&str>>STRSXPNone -> 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 TypeAll 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>NoREALSXP
Vec<u32>Yes (all <= i32::MAX)INTSXP
Vec<u32>NoREALSXP

🔗Collection Types

Rust TypeR 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 TypeR Output TypeNone Behavior
Vec<Option<Vec<T>>> / Vec<Option<Box<[T]>>> (any T with Vec<T>: IntoR)VECSXP of the vectors Vec<T> producesNULL
Vec<Option<HashSet<T>>> (T: RNativeType + Eq + Hash)VECSXP of typed vectorsNULL
Vec<Option<HashSet<String>>>VECSXP of character vectorsNULL
Vec<Option<BTreeSet<T>>> (T: RNativeType + Ord)VECSXP of sorted typed vectorsNULL
Vec<Option<BTreeSet<String>>>VECSXP of sorted character vectorsNULL
Vec<Option<HashMap<String, V>>> (V: IntoR)VECSXP of named listsNULL
Vec<Option<BTreeMap<String, V>>> (V: IntoR)VECSXP of named listsNULL
Vec<Option<&[T]>> (any T with &[T]: IntoR)VECSXP of the vectors &[T] producesNULL
Vec<Option<Vec<K>>> (keys column)VECSXP of typed vectorsNULL
Vec<Option<Vec<V>>> (V: IntoR, values column)VECSXPNULL

The Vec<Option<Vec<K>>> and Vec<Option<Vec<V>>> types appear as the companion-struct column types for HashMap<K,V> / BTreeMap<K,V> enum variant fields, expanded to <field>_keys / <field>_values columns by DataFrameRow derive. No new IntoR impls 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).

ModeRust field annotationCompanion struct typeR column(s)NA behavior
Flatten (default)(none) — inner must impl DataFrameRowVec<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 emits UnitEnumFactor + IntoR (factor SEXP) automatically. The derive rejects enums with type or const parameters, so a generic unit enum must implement UnitEnumFactor by hand if needed.
  • Struct fields: struct-typed variant fields that implement DataFrameRow flatten the same way. as_list keeps one as a list-column, which needs the struct to derive IntoList too; as_factor applies 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 TypeOk(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, invisiblySame as above — raises
Result<SEXP, E> (default, E != ())The SEXP directlySame 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, invisiblyNULL, 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 TypeDirectionWhy
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 onlyAccept 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 onlySame 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 TypeR TypeNotes
OffsetDateTimePOSIXctUTC only; tzone attr set to "UTC"
DateDateDays since 1970-01-01
DurationdifftimeSeconds unit

🔗jiff feature

Enabled with features = ["jiff"]. Bundles IANA timezone database (tzdb-bundle-always).

Rust TypeR TypeNotes
TimestampPOSIXct (UTC)Nanosecond precision; floor-based fractional-second split for correctness on negative timestamps
ZonedPOSIXct + tzone attrIANA timezone name preserved; unknown tz on input → error (no UTC fallback)
civil::DateDateDays since 1970-01-01 via Span::try_days
SignedDurationdifftime (secs)Nanosecond-precision duration stored as f64 seconds
Vec<Timestamp> (ALTREP)POSIXctJiffTimestampVec; elements materialized on access via Arc<Vec<Timestamp>>
Vec<Zoned> (ALTREP, single-tz strict)POSIXct + tzone attrJiffZonedVec; 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.

WrapperDirectionFormatType Tag
Raw<T>BothHeaderless bytesNo
RawSlice<T>BothHeaderless byte sequenceNo
RawTagged<T>Both16-byte header + bytesYes (mx_raw_type attr)
RawSliceTagged<T>Both16-byte header + byte sequenceYes (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 TypeR TypeNA HandlingNotes
i32, f64, u8, RLogical, RcomplexTyped array/matrixPasses through as the sentinel valueContiguous copy via RNativeType blanket impls
i8, i16, i64, u16, u32, u64, isize, usize, f32, boolTyped array/matrix (input only)Error on unrepresentable valuesElement-wise TryCoerce from the R native type
StringCharacter array/matrix (STRSXP)Inbound NA becomes "" (lossy, mirrors Vec<String>); never produces NA outboundExplicit 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 ValueRust RepresentationNotes
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::MINSame sentinel as NA_integer_
NA_character_R_NaString CHARSXPMapped to None in Option<String>
NaNf64::NANNot the same as NA_real_; passes through as valid f64
Inf / -Inff64::INFINITY / f64::NEG_INFINITYValid f64 values; rejected when coercing to integers
NULLR_NilValueMapped 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.