This guide documents how miniextendr converts between R and Rust types, including NA handling, coercion rules, and edge cases.

πŸ”—Basic Type Mappings

πŸ”—Scalar Types

R TypeRust TypeNotes
integer (length 1)i32NA β†’ panic
numeric (length 1)f64NA preserved as NA_REAL
logical (length 1)boolNA β†’ panic
character (length 1)String, &strNA β†’ "" (lossy); use Option to preserve NA
raw (length 1)u8No NA in raw
complex (length 1)RcomplexHas real/imag NA

πŸ”—Vector Types

R TypeRust TypeNotes
integerVec<i32>, &[i32]NA = i32::MIN
numericVec<f64>, &[f64]NA = special bit pattern
logicalVec<bool>NA β†’ error; use Vec<Option<bool>> to preserve it
characterVec<String>NA β†’ "" (lossy); use Vec<Option<String>> to preserve it
rawVec<u8>, &[u8]No NA
listVariousSee Lists and Collections sections

πŸ”—Nested Collection Types

miniextendr supports converting nested collections to R lists:

Rust TypeR TypeNotes
Vec<Vec<T>>list of vectorsFor T: RNativeType or T = String
Vec<Box<[T]>>list of vectorsBoxed slices β†’ vectors
Vec<[T; N]>list of vectorsFixed arrays β†’ vectors
Vec<HashSet<T>>list of vectorsSets β†’ unordered vectors
Vec<BTreeSet<T>>list of vectorsSets β†’ sorted vectors

These are particularly useful with #[derive(DataFrameRow)] where row fields can contain collections.

πŸ”—Option Types (NA-Safe Scalars)

Use Option<T> to handle NA safely on both input and output.

Input (R β†’ Rust): NA becomes None; non-NA becomes Some(value). Output (Rust β†’ R): None becomes the appropriate R NA; Some(v) converts normally. NULL: Option<T> also maps R’s NULL to None on input.

R TypeRust TypeInput NAInput NULLOutput None
integerOption<i32>NoneNoneNA_integer_
numericOption<f64>NoneNoneNA_real_
logicalOption<bool>NoneNoneNA (logical)
characterOption<String>NoneNoneNA_character_
characterOption<&str>NoneNoneNA_character_

Note: i32 rejects NA_integer_ (= i32::MIN) on input; you must use Option<i32> to receive integer NA. Note: bool rejects logical NA on input; use Option<bool> to receive logical NA.

This table covers owned scalars only. A reference return (Option<&i32>) or a container return (Option<Vec<T>>, Option<HashMap<...>>) maps None to NULL instead of NA β€” same for Result::Err in most shapes. See the full β€œAbsence Contract” table in CONVERSION_MATRIX.md for every return-type category, including the Result side.

πŸ”—ALTREP-Aware Types

R frequently passes ALTREP vectors (e.g., 1:10, seq_len(N)) to Rust. All parameter types handle this transparently:

Rust TypeALTREP Handling
Vec<i32>, &[f64], etc.Auto-materialized during conversion
SEXPAuto-materialized via ensure_materialized
AltrepSexpAccepted only if ALTREP, !Send + !Sync

See Receiving ALTREP from R for details.


πŸ”—NA Value Representation

πŸ”—Integer NA

pub const NA_INTEGER: i32 = i32::MIN;  // -2147483648

In R, NA_integer_ is represented as i32::MIN. This means:

  • Valid integers: -2147483647 to 2147483647
  • i32::MIN is reserved for NA

Implication: You cannot represent i32::MIN as a valid value in R integers.

πŸ”—Logical NA

pub const NA_LOGICAL: i32 = i32::MIN;  // Same as integer

R logicals are stored as integers internally:

  • TRUE = 1
  • FALSE = 0
  • NA = i32::MIN

πŸ”—Real (Double) NA

pub const NA_REAL: f64 = f64::from_bits(0x7FF0_0000_0000_07A2);

R’s NA_real_ is a specific IEEE 754 NaN with a particular bit pattern.

Critical: This is different from regular f64::NAN:

// These are DIFFERENT values
let na = NA_REAL;           // R's NA
let nan = f64::NAN;         // Regular IEEE NaN

// Detection requires bit comparison
fn is_na_real(value: f64) -> bool {
    value.to_bits() == NA_REAL.to_bits()
}

// Regular NaN check does NOT detect NA
value.is_nan()  // Returns true for both NA and NaN

Implication: When working with f64 vectors, regular NaN values pass through unchanged. Only NA_REAL is treated as NA.

πŸ”—String NA

R’s NA_character_ is a special CHARSXP pointer (R_NaString).

Plain String / &str conversion maps string NA to "", which loses the distinction between NA and an empty string. Use Option<String> for NA-safe access:

#[miniextendr]
pub fn handle_string(s: Option<String>) -> String {
    s.unwrap_or_else(|| "was NA".to_string())
}

πŸ”—Coercion System

miniextendr provides automatic type coercion for numeric types.

πŸ”—Coercion Precedence

Two traits control coercion:

  1. Coerce<R> - Infallible (always succeeds)
  2. TryCoerce<R> - Fallible (can fail)

When both exist for a type pair, Coerce takes precedence:

// Blanket impl ensures Coerce always wins
impl<T, R> TryCoerce<R> for T where T: Coerce<R> {
    fn try_coerce(self) -> Result<R, Infallible> {
        Ok(self.coerce())
    }
}

πŸ”—Infallible Coercions (Coerce)

FromToNotes
i32f64Widening (no precision loss)
i32i32Identity
f64f64Identity
Option<T>TNone β†’ NA value

πŸ”—Fallible Coercions (TryCoerce)

FromToFails When
f64i32NaN, infinity, fractional, overflow
i32u32Negative value
i32NonZeroI32Zero value
f64u64Negative, NaN, overflow

πŸ”—Enabling Coercion

Use #[miniextendr(coerce)] to enable automatic coercion:

// Without coerce: f64 parameter requires numeric input
#[miniextendr]
pub fn square(x: f64) -> f64 { x * x }

// With coerce: accepts integer, coerces to f64
#[miniextendr(coerce)]
pub fn square_coerce(x: f64) -> f64 { x * x }
square(2L)        # Error: expected numeric
square_coerce(2L) # 4.0 (integer coerced to double)

πŸ”—Per-Parameter Coercion

#[miniextendr]
pub fn mixed(
    #[miniextendr(coerce)] x: f64,  // Coerce this one
    y: i32,                          // No coercion
) -> f64 {
    x + y as f64
}

πŸ”—Option-to-NA Conversion

When returning Option<T>, None converts to R’s NA for the corresponding R type. When accepting Option<T>, R’s NA (or NULL) becomes None in Rust.

#[miniextendr]
pub fn maybe_value(x: i32) -> Option<i32> {
    if x > 0 { Some(x) } else { None }
}
maybe_value(5L)   # 5L
maybe_value(-1L)  # NA_integer_

String NA round-trip:

#[miniextendr]
pub fn handle_na_string(s: Option<String>) -> Option<String> {
    s.map(|x| x.to_uppercase())
}
handle_na_string("hello")     # "HELLO"
handle_na_string(NA_character_)  # NA_character_

NULL on input:

#[miniextendr]
pub fn nullable(x: Option<i32>) -> i32 {
    x.unwrap_or(-1)
}
nullable(NULL)        # -1
nullable(NA_integer_) # -1
nullable(42L)         # 42

πŸ”—Coercion for Options

Option<T> coerces to T with None β†’ NA:

// This works with coercion enabled:
// R's NA_integer_ β†’ None β†’ coerced to NA_real_
#[miniextendr(coerce)]
pub fn option_coerce(x: f64) -> f64 { x }

πŸ”—Vector NA Handling

πŸ”—NA-Aware Vector Types

Both Vec<Option<T>> and Box<[Option<T>]> are supported for reading and writing NA-aware vectors. Element-level semantics: None ↔ NA, Some(v) ↔ concrete value.

Input (R β†’ Rust):

R TypeRust TypeNA sentinel mapped to None
INTSXPVec<Option<i32>>, Box<[Option<i32>]>i32::MIN (NA_integer_)
REALSXPVec<Option<f64>>, Box<[Option<f64>]>specific NaN bit pattern (NA_real_)
LGLSXPVec<Option<bool>>, Box<[Option<bool>]>i32::MIN (NA_logical_)
STRSXPVec<Option<String>>, Box<[Option<String>]>R_NaString (NA_character_)

Coerced numeric types also support Vec<Option<T>> (accepts INTSXP, REALSXP, RAWSXP, LGLSXP):

Rust TypeAccepted R TypesNA handling
Vec<Option<i64>>INTSXP, REALSXP, RAWSXP, LGLSXPNA_integer_/NA_real_ β†’ None
Vec<Option<u64>>INTSXP, REALSXP, RAWSXP, LGLSXPNA_integer_/NA_real_ β†’ None
Vec<Option<i8>>, Vec<Option<i16>>, etc.INTSXP, REALSXP, RAWSXP, LGLSXPNA β†’ None

Output (Rust β†’ R):

Rust TypeR OutputNone becomes
Vec<Option<i32>>INTSXPNA_integer_
Vec<Option<f64>>REALSXPNA_real_
Vec<Option<bool>>LGLSXPNA (logical)
Vec<Option<String>>STRSXPNA_character_

πŸ”—Reading Vectors with NA

For vectors with potential NA values, use Option element type:

#[miniextendr]
pub fn count_na(x: Vec<Option<i32>>) -> i32 {
    x.iter().filter(|v| v.is_none()).count() as i32
}

Use Box<[Option<T>]> when you prefer a fixed-size owned slice over Vec:

#[miniextendr]
pub fn sum_non_na(x: Box<[Option<f64>]>) -> f64 {
    x.iter().filter_map(|v| *v).sum()
}

πŸ”—Writing Vectors with NA

Return Vec<Option<T>> to include NA values:

#[miniextendr]
pub fn add_na_at_end(x: Vec<i32>) -> Vec<Option<i32>> {
    let mut result: Vec<Option<i32>> = x.into_iter().map(Some).collect();
    result.push(None);  // Adds NA
    result
}

πŸ”—Partial-results pattern

#[miniextendr]
pub fn parse_numbers(strings: Vec<String>) -> Vec<Option<f64>> {
    strings.iter()
        .map(|s| s.parse().ok())  // failed parses become None (NA in R)
        .collect()
}

πŸ”—Slice Lifetimes

When using slice parameters (&[T]), be aware of lifetime implications:

// SAFE: Slice is only used during function execution
#[miniextendr]
pub fn sum(x: &[f64]) -> f64 {
    x.iter().sum()
}

The slice has a 'static lifetime annotation, but this is a lie for API convenience. The actual lifetime is tied to R’s GC protection of the SEXP.

Safe patterns:

  • Use slice within the function
  • Copy data if you need to store it

Unsafe patterns:

  • Storing the slice in a struct that outlives the function
  • Returning the slice (won’t compile anyway)

πŸ”—String Lifetimes

Borrowed strings point directly into a CHARSXP; they do not own or root the R object. When you get a &str from R:

#[miniextendr]
pub fn process_string(s: &str) -> String {
    // s is valid for this .Call while the argument remains reachable
    s.to_uppercase()
}

The Rust type currently carries a 'static lifetime for API convenience, but that lifetime is no more real than it is for borrowed numeric slices. R can garbage-collect a CHARSXP once it is unreachable. Use the borrow only within the .Call; copy to String before storing it. NA_character_ becomes "" on the plain borrowed/owned string paths, so use Option<&str> or Option<String> when NA must remain distinguishable from an empty string.


πŸ”—ExternalPtr Semantics

When using #[derive(ExternalPtr)]:

#[derive(ExternalPtr)]
pub struct MyData {
    values: Vec<f64>,
}

The Rust data is heap-allocated and owned by R:

  1. new() allocates Rust data on heap
  2. Pointer stored in R’s external pointer SEXP
  3. R’s GC tracks the SEXP
  4. When SEXP is collected, Rust Drop runs
  5. Heap memory freed

Thread safety: ExternalPtr<T> is Send when T: Send, allowing a sequential ownership handoff between worker and R threads. It is not Sync and must not be accessed concurrently. Operations that touch the SEXP or R’s protection machinery are routed to the R thread.


πŸ”—Complex Types

πŸ”—Lists

Lists convert to various Rust types:

// Named list β†’ HashMap
#[miniextendr]
pub fn process_map(x: HashMap<String, i32>) -> i32 {
    x.values().sum()
}

// List β†’ Vec of heterogeneous items (requires SEXP)
#[miniextendr]
pub fn list_length(x: List) -> i32 {
    x.len() as i32
}

πŸ”—Data Frames

Accept a validated DataFrame view and use its typed column accessors:

#[miniextendr]
pub fn get_column(df: DataFrame, name: &str) -> Result<Vec<f64>, String> {
    df.column(name)
        .ok_or_else(|| format!("missing or non-numeric column {name:?}"))
}

Rust-side constructors return BuiltDataFrame, a rooted owner that dereferences to DataFrame and implements IntoR. See Data Frames for the view/handle split and conversion traits.

πŸ”—Dynamic R-native values (RValue)

RValue is an owned, Send value tree for dynamic base-R data. It covers NULL, logical/integer/double/complex/character/raw vectors, and recursive named or unnamed lists without carrying a live SEXP across threads:

use miniextendr_api::RValue;

let payload = RValue::List(vec![
    (Some("count".into()), RValue::from(3i32)),
    (Some("labels".into()), RValue::from(vec!["a", "b"])),
]);

It implements TryFromSexp and IntoR, preserves NA in its typed vector variants, and is the representation used by structured condition data = payloads. It deliberately does not model closures, environments, language objects, S4 objects, external pointers, or ALTREP internals.

πŸ”—Matrices

With the ndarray feature:

use ndarray::Array2;

#[miniextendr]
pub fn matrix_sum(x: Array2<f64>) -> f64 {
    x.sum()
}

Character matrices/arrays are supported through explicit element-wise impls (R’s STRSXP is a vector of CHARSXP pointers, not contiguous memory, so the contiguous-copy path used for numeric arrays does not apply). Shape is preserved as R’s dim attribute with the same column-major contract as the numeric conversions, and Option<String>::None maps to NA_character_:

use ndarray::Array2;

#[miniextendr]
pub fn label_grid(x: Array2<Option<String>>) -> Array2<Option<String>> {
    x // NA-safe round-trip: None <-> NA_character_
}

Array<String, D> (without Option) mirrors Vec<String>: reading maps NA_character_ to "" (lossy); writing never produces NA.


πŸ”—Error Cases

πŸ”—Type Mismatch

When R type doesn’t match expected Rust type:

#[miniextendr]
pub fn needs_integer(x: i32) -> i32 { x }
needs_integer(1.5)
# Error: failed to convert parameter 'x' to i32: wrong type

πŸ”—NA in Non-Option

When NA is passed to non-Option parameter:

#[miniextendr]
pub fn needs_value(x: i32) -> i32 { x }
needs_value(NA_integer_)
# Error: failed to convert parameter 'x' to i32: contains NA

πŸ”—Coercion Failure

When coercion fails:

#[miniextendr(coerce)]
pub fn needs_int(x: i32) -> i32 { x }
needs_int(1.5)
# Error: failed to coerce parameter 'x' to i32: fractional value

πŸ”—Feature-Gated Types

Many additional types are available via Cargo features:

FeatureTypes
num-bigintBigInt, BigUint
rust_decimalDecimal
uuidUuid
timeDate, Time, OffsetDateTime
ndarrayArray1, Array2, etc.
nalgebraMatrix, Vector, etc.
indexmapIndexMap, IndexSet
serdeNative R serialization
serde_jsonJSON string serialization (also enables serde)

Enable in Cargo.toml:

[dependencies]
miniextendr-api = { version = "0.1", features = ["uuid", "time"] }

πŸ”—Best Practices

  1. Use Option<T> for NA-safe parameters

    pub fn safe(x: Option<i32>) -> i32 { x.unwrap_or(0) }
  2. Use slices for read-only vector access (zero-copy)

    pub fn sum(x: &[f64]) -> f64 { x.iter().sum() }
  3. Use Vec<T> when you need to modify

    pub fn double(x: Vec<i32>) -> Vec<i32> { x.into_iter().map(|v| v*2).collect() }
  4. Enable coercion for flexible numeric APIs

    #[miniextendr(coerce)]
    pub fn flexible(x: f64) -> f64 { x }
  5. Return Option<T> to produce NA values

    pub fn maybe(x: i32) -> Option<i32> { if x > 0 { Some(x) } else { None } }

πŸ”—Named Lists

R lists with names can be accessed via NamedList, which builds a HashMap index for O(1) lookup:

use miniextendr_api::NamedList;

#[miniextendr]
pub fn get_option(config: NamedList) -> Option<String> {
    config.get::<String>("name")
}
MethodDescription
get::<T>(name)O(1) lookup by name, converting to type T
get_raw(name)O(1) lookup returning raw SEXP
contains(name)Check if a name exists
get_index::<T>(i)Positional access (no name lookup)
len() / is_empty()Size queries

When to use: List::get_named() is fine for a single lookup. Use NamedList when you need multiple lookups on the same list (O(n) build + O(1) per lookup vs O(n) per lookup).

NamedList implements TryFromSexp, so it can be used directly as a function parameter. NA and empty-string names are excluded from the index; duplicate names resolve to the last occurrence.


πŸ”—Mutable Input

There are two ways to mutate an R vector’s contents from Rust.

πŸ”—Copy-in / copy-out (Vec<T>)

Vec<T> copies the R vector on input (TryFromSexp), lets you mutate the owned copy, and copies out to a new R vector on return (IntoR). The caller’s original vector is untouched:

#[miniextendr]
pub fn double(mut x: Vec<f64>) -> Vec<f64> {
    for v in x.iter_mut() {
        *v *= 2.0;
    }
    x // copies out to a new R vector on return
}

πŸ”—In-place, zero-copy (&mut [T])

&mut [T] (and Option<&mut [T]>) borrow R’s data pointer directly and mutate the caller’s vector in place β€” no copy in, no copy out. This is faster for large vectors but only works for the contiguous atomic types (i32, f64, u8; not strings), and it mutates the object the caller passed:

#[miniextendr]
pub fn add_one_in_place(x: &mut [i32]) {
    for v in x.iter_mut() {
        *v += 1;
    }
}

Aliasing foot-gun (#1104). Because &mut [T] borrows R’s buffer without copying, binding the same R vector to two slice parameters β€” e.g. f(x, x) from R β€” hands out two views over one buffer. That is undefined behavior in Rust whenever at least one of the two borrows is mutable: two &mut [T], or a &mut [T] paired with a &[T] (a &[T] is also a zero-copy view over R’s buffer, so a shared borrow aliasing a mutable one is UB too). When a #[miniextendr] function has two or more slice-family parameters and a pair of them could alias with one being mutable, the generated wrapper emits a debug_assert! that compares the underlying SEXP identities before conversion and raises an error naming both parameters if they share one object. (Two shared &[T] reads over one vector are sound and are not flagged. SEXP identity, not the raw data pointer, is compared, so two distinct empty vectors β€” which share R’s 0x1 sentinel data pointer β€” are not a false positive.) This check is debug-build only (zero cost in release), so in a release build the aliasing call is not rejected β€” don’t rely on the guard as a correctness boundary; pass distinct vectors. If two arguments might reference the same vector and either mutates it, take Vec<T> (copy-in/copy-out) for at least one of them.

The wrapper-level identity check compares only top-level parameter SEXPs. It does not catch a vector reached through a list element aliasing a direct slice parameter, such as f(list(v), v) when the first parameter is Vec<&mut [T]>. The list conversion separately rejects duplicates within the list, but there is no cross-site registry between nested and direct borrows. Treat those arguments as potentially aliasing and copy at least one side; #1252 tracks the residual risk.


πŸ”—Known Limitations

  • SEXP slice lifetimes use 'static for convenience, but actual lifetime is tied to GC protection scope.

See GAPS.md for the full catalog of known limitations and workarounds.


πŸ”—See Also