miniextendr provides four macros for raising structured R conditions from Rust. They ride the tagged-condition transport that every #[miniextendr] function uses. A deferred form signals a warning, message or condition and still returns the call’s value.

🔗Quick reference

MacroR equivalentDefault classUnhandled behaviour
error!(...)stop()rust_errorterminates execution
warning!(...)warning()rust_warningprints, continues
message!(...)message()rust_messageprints, continues
condition!(...)signalCondition()rust_conditionsilent no-op

All four support an optional class = ... argument to prepend custom classes for programmatic catching (one string or a vector, most specific first), and an optional data = ... argument to attach structured named fields readable as e$<name> in handlers. error!, warning! and condition! also take a leading call = none, which raises the condition without a call, like R’s call. = FALSE (see Conditions without a call). Result<T, E> returns get the same treatment through the RConditionData trait (hand-written or derived), or, for error enums that already derive serde::Serialize, through the serde shape. An R error caught by RCall::eval, r_eval_str or try_eval_with_handlers (an REvalError) is raised again the same ways, keeping R’s message and classes (see Raising a caught R error as your own).

A fifth macro, arg_error!(param = "x", …), raises the condition of the wrapper’s own argument checks from a body (see Argument errors from a body).

Import note. error! and condition! are shadowed by the crate-root modules error / condition, so use miniextendr_api::*; (or a direct use miniextendr_api::error;) resolves to the module, not the macro. Use the collision-free aliases rust_error! / rust_condition! (identical expansions), or invoke the bare names fully qualified. warning! / message! have no such clash.

🔗How it works

Each macro calls std::panic::panic_any(RCondition::...). The panic is caught by with_r_unwind_protect before Rust destructors have unwound, which recognises the RCondition payload and converts it to a tagged SEXP (5-element list: error, kind, class, call, data). The generated R wrapper reads the SEXP and dispatches to the appropriate R signal function.

The class slot carries the optional user-supplied class. When non-NULL it is prepended to the standard layered vector. The data slot carries the optional named-list payload; the R helper splices its fields into the condition object alongside message / call / kind. The call is the one the wrapper passed to .Call(): its own call as written; for an S3 method the generic’s call, summary(x) for a method R called as summary.mx_rec(x) and x$f <- value for a replacement method; and no call at all under #[miniextendr(call = none)] (see CALL_ATTRIBUTION.md).

Deferred conditions take a shorter route: defer_warning & co. push the same payload onto a queue instead of panicking, and the generated C wrapper drains the queue once the body has returned, handing each entry (as the same tagged SEXP) to the same R helper from Rust before the value reaches R.

🔗Class layering

class(e)
# error!("...")         → c("rust_error",     "simpleError",     "error",   "condition")
# warning!("...")       → c("rust_warning",   "simpleWarning",   "warning", "condition")
# message!("...")       → c("rust_message",   "simpleMessage",   "message", "condition")
# condition!("...")     → c("rust_condition", "simpleCondition",            "condition")

# With class = "my_err":
class(e)
# error!(class = "my_err", "...") → c("my_err", "rust_error", "simpleError", "error", "condition")

# With a class vector (member first, family second), so handlers can catch either:
# error!(class = ["pkg_error_missing_field", "pkg_error"], "...")
#   → c("pkg_error_missing_field", "pkg_error", "rust_error", "simpleError", "error", "condition")

class = accepts anything implementing ConditionClass: &str, String, [&str; N], Vec<String>, slices. The same vector form is available on warning! and condition!.

The framework layers are part of the contract. Every condition raised from Rust carries the user classes first, then the framework’s layers: rust_error (so a handler can catch anything raised from Rust), simpleError (so code written for stop() errors keeps working), then error and condition. They cannot be left out. A Rust method that replaces an R one raising errorCondition(class = "subscriptOutOfBoundsError") therefore raises c("subscriptOutOfBoundsError", "rust_error", "simpleError", "error", "condition") where R raised c("subscriptOutOfBoundsError", "error", "condition"). tryCatch(subscriptOutOfBoundsError = ), inherits() and expect_s3_class() without exact treat the two alike; only a comparison of the whole class vector sees the difference, so compare the leading classes (below).

🔗Testing the class vector

testthat’s expect_s3_class(e, c("pkg_error_missing_field", "rust_error", "error")) is inherits(e, class): it passes when any one of the listed classes is present. A test written that way keeps passing when a layer disappears, such as the custom class dropped, rust_error replaced by a bare simpleError, or a family class lost at a version bump. To pin the layering, assert the whole vector with exact = TRUE:

e <- tryCatch(check(150), error = identity)
testthat::expect_s3_class(
  e,
  c("pkg_error_out_of_range", "pkg_error", "rust_error", "simpleError", "error", "condition"),
  exact = TRUE
)

When only the leading layers are yours to assert, or a test should pass for an R and a Rust implementation of the same method, compare the prefix instead:

cls <- c("pkg_error_out_of_range", "pkg_error", "rust_error")
testthat::expect_identical(class(e)[seq_along(cls)], cls)

A small helper in tests/testthat/helper-conditions.R makes the exact form the easy one to reach for:

expect_condition_classes <- function(x, classes) {
  testthat::expect_identical(class(x)[seq_along(classes)], classes)
}

🔗Runnable examples

🔗error!()

library(miniextendr)

# Raised by: error!("something went wrong: {x}")

e <- tryCatch(demo_error("oops"), error = function(e) e)
class(e)
# [1] "rust_error"  "simpleError" "error"       "condition"
conditionMessage(e)
# [1] "oops"

# Specific handler:
tryCatch(demo_error("x"), rust_error = function(e) "caught by rust_error handler")
# [1] "caught by rust_error handler"

🔗error!() with custom class

# Raised by: error!(class = "my_error", "missing field: {name}")

tryCatch(
  demo_error_custom_class("my_error", "missing field: x"),
  my_error   = function(e) paste("custom:", conditionMessage(e)),
  rust_error = function(e) paste("rust:",   conditionMessage(e))
)
# [1] "custom: missing field: x"

🔗error!() with structured data payloads

Rust-side, the macros accept data = ("name", value) for a single field or data = [("a", v1), ("b", v2)] for several (rlang abort(data = list(...)) style). Argument order is fixed: call = none (optional, see Conditions without a call), then class = ... (optional), then data = ... (optional), then the format message:

// Single field:
error!(class = "range_error", data = ("value", value), "value {value} out of range");

// Multiple fields:
error!(
    class = "validation_error",
    data = [("value", value), ("code", code), ("label", label), ("fatal", true)],
    "validation failed for {label}"
);

R-side, handlers read the fields directly from the condition object:

# Raised by: error!(class = "range_error", data = ("value", value), "value {value} out of range")

e <- tryCatch(demo_error_data_scalar(150L), range_error = function(e) e)
e$value
# [1] 150

# Programmatic recovery — clamp instead of parsing the message:
tryCatch(
  demo_error_data_scalar(150L),
  range_error = function(e) min(max(e$value, 0L), 100L)
)
# [1] 100

🔗Supported data value types

Rust valueR field type
i32integer(1)
f64double(1)
boollogical(1)
&str / Stringcharacter(1)
Vec<i32>integer(n)
Vec<f64>double(n)
Vec<bool>logical(n)
Vec<String> / Vec<&str>character(n)
Option<T> / Vec<Option<T>> for the scalar families abovetyped NA
i64 / u32integer when it fits; otherwise double
(A, B, ...) tuples (1–12 fields) of these valuesunnamed list, one element per field
RValue::NullNULL
RValue::Complex, RValue::Rawcomplex / raw vector
RValue::Listrecursively nested, optionally named list

RValue is the owned, Send, R-native value tree used by the condition transport. Build it directly for nested or heterogeneous values, or use RValue::debug(value) to attach an eager Debug rendering when no native R mapping is appropriate.

🔗Worker-thread note

The payload travels through panic_any, which requires Send — and the macro may fire on the worker thread, where a live SEXP is illegal to carry. Each field is therefore converted at the call site into RValue, and a multi-field payload becomes ConditionData (Vec<(String, RValue)>). The actual R objects are materialised on R’s main thread at the unwind boundary. Consequently, data = ... works identically from worker-thread and main-thread code, but a live SEXP or arbitrary IntoR value cannot ride along.

🔗Reserved names

message, call and kind are the condition’s own slots (the R helper splices data over them with utils::modifyList, on every transport, the raising guards included, #1315), and .mx_parent carries a cause attached with with_parent, so they are rejected as field names: at compile time when the name is a literal or a bare identifier (data = ("kind", 1), data = { kind = 1 }), at runtime otherwise (a plain rust_error explaining the clash, instead of the former silent overwrite).

There is no escape hatch, deliberately: message and call are what R’s conditionMessage() / conditionCall() read, and kind is the framework’s transport tag: it records which path raised the condition ("error" for error!, "result_err" for a Result’s Err, "panic", "conversion", …), not what the condition is, so one condition class raised from two paths has two kinds; match conditions on their class. A field with one of those names is therefore a naming clash to fix where the payload is built (a different key, #[serde(rename)] on a derived struct, a rename in your RConditionData::data() impl). Renaming every field with a prefix to rescue one would make e$<name> access inconsistent across conditions. When the payload is an error type’s own serde shape that other consumers read, do the renaming at the condition boundary instead: #[miniextendr(serde_error(skip(..), rename(..)))] drops or renames payload fields, and a message field that merely echoes Display is dropped with no option (see Payload fields named message). The same attribute gives fields their R types (as_character(..) & co.), and a shape declared once for the error type with serde_error_shape! holds all of it for every function that returns the type (see Field types and shared shapes).

error!(
    class = "pkg_sparse_rule",
    data = { rule = kind, column = column },
    "sparse rule on `{column}`"
);
e <- tryCatch(f(), pkg_sparse_rule = function(e) e)
e$rule      # the field
e$kind      # still "error" (the transport tag)

🔗warning!()

# Raised by: warning!("x is large: {x}")

# tryCatch absorbs the warning and returns the handler result:
tryCatch(demo_warning("watch out"), rust_warning = function(w) "caught!")
# [1] "caught!"

# withCallingHandlers resumes execution after the handler:
result <- withCallingHandlers(
  {
    demo_warning("note")
    42L
  },
  warning = function(w) {
    cat("saw:", conditionMessage(w), "\n")
    invokeRestart("muffleWarning")
  }
)
# saw: note
result
# [1] 42

🔗message!()

# Raised by: message!("step {n} complete")

demo_message("hello")
# hello

suppressMessages(demo_message("silenced"))
# (no output)

# withCallingHandlers — muffleMessage restart stops the default printing:
withCallingHandlers(
  demo_message("intercepted"),
  message = function(m) {
    cat("caught:", conditionMessage(m))
    invokeRestart("muffleMessage")
  }
)
# caught: intercepted

🔗condition!()

# Raised by: condition!("step 1 of 10")
# Without a handler, signalCondition returns NULL invisibly.

demo_condition("silent signal")
# NULL

# With a handler:
withCallingHandlers(
  demo_condition("progress event"),
  condition = function(c) cat("progress:", conditionMessage(c), "\n")
)
# progress: progress event
# NULL

# With a custom class:
withCallingHandlers(
  demo_condition_custom_class("my_progress", "step 3"),
  my_progress = function(c) cat("progress:", conditionMessage(c), "\n")
)
# progress: step 3
# NULL

🔗Deferred conditions: a warning and a value

warning!(), message!() and condition!() unwind the Rust function: R sees the signal and the call returns invisible(NULL). When the result is still worth returning (“here are the rows, two were dropped”), queue the condition instead of raising it. The generated wrapper signals everything queued during the call after the Rust body has returned, in queue order, and then hands the value to R:

use miniextendr_api::RCondition;
use miniextendr_api::{defer_warning, miniextendr};

#[derive(Debug, RCondition)]
#[condition(class = "pkg_warning")]
pub enum PkgWarning {
    #[condition(message = "dropped {dropped} of {total} rows")]
    Truncated { dropped: i32, total: i32 },
}

#[miniextendr]
pub fn trim(n: i32) -> i32 {
    defer_warning(PkgWarning::Truncated { dropped: 2, total: n });
    n - 2
}
trim(5L)
# [1] 3
# Warning message:
# In trim(5L) : dropped 2 of 5 rows

w <- tryCatch(trim(5L), pkg_warning_truncated = function(w) w)
class(w)
# [1] "pkg_warning_truncated" "pkg_warning" "rust_warning" "simpleWarning" "warning" "condition"
w$dropped
# [1] 2
suppressWarnings(trim(5L))
# [1] 3
withCallingHandlers(trim(5L), warning = function(w) invokeRestart("muffleWarning"))
# [1] 3

What R sees is what warning() called from an R function would produce, because that is what runs: the same R helper that re-raises the panicking macros’ conditions signals each queued one. Class layering, w$<name> data fields and conditionCall() (the wrapper’s call, or NULL for a condition queued without a call) are identical to the immediate forms. tryCatch(warning = ) exits before the value is returned; calling handlers that muffle keep it.

FunctionMacroR signal
defer_warning(payload)defer_warning!(class = …, data = …, "fmt")warning()
defer_message(payload)defer_message!(…)message()
defer_condition(payload)defer_condition!(…)signalCondition()

The functions take any RConditionData payload: a derived or hand-written type, or an RError built on the spot. The macros take the warning! grammar (call = none, then class = …, then data = …, then the format message). defer_message honours the payload’s classes, layered in front of rust_message, which the message! macro has no syntax for.

Ordering. Queued conditions are signalled after the Rust body finished and before R sees its outcome. A deferred warning followed by rust_error! or an Err return is signalled first and the error second; several deferred conditions keep their queue order; nested calls (R code evaluated from Rust that calls another #[miniextendr] function) flush only what they queued themselves.

Warning placement. Conditions queued before a failure still signal. If a body must not warn on failure, collect warning payloads locally and queue them only after its last fallible step (including any ?). Moving a warning before a later ? changes that behavior: returning Err does not cancel the queued warning.

use miniextendr_api::{defer_warning, miniextendr};

#[miniextendr]
pub fn parse_trimmed(text: String) -> Result<i32, std::num::ParseIntError> {
    let trimmed = text.trim();
    let value = trimmed.parse::<i32>()?;
    if trimmed != text {
        defer_warning!(class = "pkg_trimmed", "removed surrounding whitespace");
    }
    Ok(value)
}

Here, parse_trimmed(" bad ") errors without warning; parse_trimmed(" 42 ") warns about the whitespace and returns 42L.

Capturing a block’s conditions. deferred_condition::capture(f) runs f and returns what it queued, oldest first, instead of signalling it: for code whose diagnostics the caller already has, such as replaying the steps that built a saved object. capture(f).0 discards them; each RCondition can also be inspected, queued again with .defer() or raised with .raise().

use miniextendr_api::deferred_condition::capture;

let (rebuilt, _replayed) = capture(|| rebuild(&snapshot));

Conditions queued before the block, or by enclosing calls, stay queued, and a nested #[miniextendr] call still signals its own when it returns. If f unwinds, its conditions stay queued and follow the rules below (a Rust failure signals them, an R-origin error discards them); to keep them on failure too, catch the failure inside the block with catch_condition: capture(|| catch_condition(|| rebuild(&snapshot))).

Where it works. Every #[miniextendr] function and method, including #[miniextendr(worker)] bodies (the queue is a Mutex: the push happens on the worker, the signal on R’s main thread once the result is back), rng wrappers and trait-ABI vtable shims. ALTREP RUnwind and RustUnwind callbacks, with_r_unwind_protect_or_raise, and connection I/O callbacks flush their own conditions before returning. ALTREP and connection conditions have conditionCall() = NULL; the raising guard uses its explicit call argument (none for a condition raised with call = none). For example, an ALTREP elt() implementation can queue defer_warning! and return the element: suppressWarnings(x[1L]) keeps that element, while tryCatch(x[1L], warning = identity) exits with the warning. The constructor’s .Call has already returned; a later unrelated call does not own that warning.

Callback results remain alive while handlers run. ALTREP’s SEXP trampolines root their results automatically. Generic low-level guards cannot inspect R objects hidden in arbitrary Rust values: return OwnedProtect (or another rooted handle) for an allocated SEXP and extract it after the guard returns. Calling handlers may allocate or re-enter Rust. Every queued entry is signalled in order, including identical entries; callbacks that run repeatedly should queue only the diagnostics their API intends to emit.

A Rust failure signals queued conditions first. An R-origin error or exiting handler abandons the callback and discards its remaining queue, rather than trying to signal while R is already unwinding. options(warn = 2) and warning handlers that exit follow the same rule.

Finalization. External-pointer finalizers and connection close/destroy/Drop callbacks suppress deferred conditions, including nested guarded work. R’s finalizer context clears the caller’s condition handlers; these conditions are neither signalled there nor retained for a future call. The same rule applies to destructors run through drop_catching_panic. Use explicit methods for user-visible diagnostics before releasing an object. Raw unguarded FFI and AltrepGuard::Unsafe callbacks must not queue conditions; select a guarded callback when deferred signalling is needed.

🔗Classed Result errors with RConditionData and RError

A #[miniextendr] function or method returning Result<T, E> raises Err(e) as an R error whose kind is "result_err". The Err arm picks the class vector and data in three steps, the first that applies winning:

  1. E implements miniextendr_api::condition::RConditionData (this section).
  2. The API crate’s serde feature is on and E: serde::Serialize + Display (derived from the serde shape).
  3. Otherwise a bare rust_error whose message is format!("{e:?}").

To give R handlers something to dispatch on without giving up ? composition, implement RConditionData for the error type:

use miniextendr_api::condition::{ConditionData, RConditionData};

#[derive(Debug, thiserror::Error)]
pub enum PkgError {
    #[error("field `{field}` is missing")]
    MissingField { field: String },
    #[error("{value} exceeds the maximum {max}")]
    OutOfRange { value: f64, max: f64 },
}

impl RConditionData for PkgError {
    fn message(&self) -> String { self.to_string() }
    fn class(&self) -> Vec<String> {
        let member = match self {
            PkgError::MissingField { .. } => "pkg_error_missing_field",
            PkgError::OutOfRange { .. } => "pkg_error_out_of_range",
        };
        vec![member.into(), "pkg_error".into()]
    }
    fn data(&self) -> Option<ConditionData> {
        Some(match self {
            PkgError::MissingField { field } => vec![("field".into(), field.as_str().into())],
            PkgError::OutOfRange { value, max } =>
                vec![("value".into(), (*value).into()), ("max".into(), (*max).into())],
        })
    }
}

#[miniextendr]
pub fn check(value: f64) -> Result<f64, PkgError> {
    if value > 100.0 { return Err(PkgError::OutOfRange { value, max: 100.0 }); }
    Ok(value)
}
e <- tryCatch(check(150), error = function(e) e)
class(e)
# [1] "pkg_error_out_of_range" "pkg_error" "rust_error" "simpleError" "error" "condition"
e$value; e$max
# [1] 150
# [1] 100
tryCatch(check(150), pkg_error = function(e) "family handler")
# [1] "family handler"

In tests, assert this vector exactly (expect_s3_class(e, classes, exact = TRUE) or a prefix comparison, see Testing the class vector); the any-of default of expect_s3_class() cannot tell a dropped layer from a present one.

Detection is by trait, not by attribute: the generated Err arm probes E: RConditionData first and falls back to the Debug rendering otherwise, so existing Result<T, String> functions behave exactly as before. kind stays "result_err". Field names follow the reserved-name rule above; a reserved name from data() raises a rust_error describing the clash.

The same trait classes argument-conversion failures: when a parameter type’s TryFromSexp::Error implements RConditionData, a failed conversion raises its classes and fields with kind = "conversion", the parameter’s name as e$param and its Rust type as e$rust_type. The R-side argument checks raise the same condition (#1591). See ERROR_HANDLING.md.

For one-off cases, RError is a ready-made classed value. Any std::error::Error converts into it with ? or From (the message keeps the caused by: chain), and builders add the R-facing parts:

use miniextendr_api::condition::RError;

#[miniextendr]
pub fn parse_port(s: &str) -> Result<i32, RError> {
    let port: i32 = s
        .parse()
        .map_err(|e| RError::from(e).class(["pkg_bad_port", "pkg_error"]).data("input", s))?;
    Ok(port)
}

The .without_call() builder raises the error with no call (Conditions without a call). RError implements Display (the message) but not std::error::Error, which keeps the blanket From<E: Error> coherent; it works with #[miniextendr(unwrap_in_r)] too.

🔗Deriving RCondition

#[derive(RCondition)] writes the impl above from the type’s shape, and a conversion into an owned RCondition (below):

use miniextendr_api::RCondition;

#[derive(Debug, RCondition)]
#[condition(class = "pkg_error")]
pub enum PkgError {
    #[condition(message = "field `{field}` is missing")]
    MissingField { field: String },
    #[condition(message = "{value} exceeds the maximum {max}")]
    OutOfRange { value: f64, max: f64 },
}
  • Classes. The family is #[condition(class = "…")] on the type, else the type name in snake_case (PkgError → pkg_error). Each variant reports c(<member>, <family>); the member is the same attribute on the variant, else <family>_<variant in snake_case> (pkg_error_out_of_range). A struct reports c(<family>). The list form, #[condition(class("…", "…"))], most specific first, is a struct’s whole class vector, and on a variant it replaces the member, with the family still last:

    #[derive(RCondition)]
    #[condition(kind = message, class("pkg_message_selection_dropped", "pkg_message"))]
    struct SelectionDropped { missing: Vec<String> }
    // class: pkg_message_selection_dropped, pkg_message, rust_message, ...

    On an enum the type takes class = "…" only: it names the family.

  • Message. #[condition(message = "…")] on a struct or variant is a format! string with the fields in scope (tuple fields as _0, _1, …). Without it the message is the type’s Display rendering, so a thiserror #[error("…")] stays the single source of the text.

  • Data. Every field becomes a data entry under its own name, converted with RValue::from(field.clone()) (the supported value types). #[condition(rename = "…")] changes the name, #[condition(skip)] leaves the field out, and #[condition(debug)] attaches the Debug rendering (RValue::debug) for a type without an R mapping. #[condition(skip_none)] on an Option field leaves its entry out when it is None, instead of a typed NA. #[condition(data(name = value, …))] on the type or a variant adds entries after the fields’ (the type’s, then the variant’s), each RValue::from(value): data(param = "unit_table") gives every such condition e$param. Tuple fields need rename or skip. The reserved slots message, call and kind are compile errors, for fields and data(...) entries alike, as are duplicate names and type or const parameters. Lifetime parameters are fine: a payload can borrow, since every entry is converted to an owned RValue.

  • Call. #[condition(call = none)] on the type (every variant) or on a variant raises the condition without a call (Conditions without a call); the others keep the wrapper’s call.

  • Argument message. #[condition(argument_message = "…")] on a struct or variant, a format! string like message, is the whole message when the error refuses an argument in a conversion (a type’s TryFromSexp or its validate check), in place of '<p>' must be …: <message>. A variant without it keeps the prefixed message. In a list of objects (a Vec of a newtype over List) it words the refused element, before its position. See EXTENDING_MINIEXTENDR.md.

The same derive serves deferred conditions and Result<T, E> returns.

🔗Typed warnings, messages and mixed condition enums

The derive also converts the type into an owned RCondition. Its kind is #[condition(kind = …)]: on the type it sets the default, and an enum variant can override it. The choices are error (the default), warning, message and condition.

use miniextendr_api::RCondition;

#[derive(RCondition)]
#[condition(kind = warning, class = "pkg_warning", message = "dropped {n} rows")]
struct DroppedRows {
    n: i32,
}

// Queue a warning and keep computing the return value.
RCondition::from(DroppedRows { n: 2 }).defer();

#[derive(RCondition)]
#[condition(kind = warning, class = "pkg_diagnostic")]
enum Diagnostic {
    #[condition(message = "dropped {n} rows")]
    Dropped { n: i32 },
    #[condition(kind = message, message = "finished {n} rows")]
    Finished { n: i32 },
    #[condition(kind = error, message = "input is empty", call = none)]
    Empty,
}

RCondition::from(Diagnostic::Finished { n: 10 }).defer();

.defer() queues the declared kind until the current guarded call returns. .raise() uses panic_any(RCondition) to leave the Rust body immediately; muffling a warning in R does not resume that body. Both routes preserve the classes, fields and call policy. Plain panic_any(DroppedRows { n: 2 }) does not perform this conversion; convert into RCondition first.

The kind is read only through RCondition::from. A value returned as Err(x) from a Result<T, E> is always an R error, whatever kind it declares: Err(Diagnostic::Finished { n }) stops the call instead of printing a message. defer_warning(x) / defer_message(x) / defer_condition(x) also signal the kind they are named after. Convert with RCondition::from(x) when the declared kind should decide.

🔗Deriving the classes from a serde error type

When the error type already derives serde::Serialize (for logging, JSON transport, or a downstream client), the same information drives the R condition without an RConditionData impl and without any attribute: with the API crate’s serde feature enabled, every Result<T, E> whose E: Serialize + Display serializes the Err value. The enum variant becomes the member class <prefix>_<variant>, the variant’s fields become e$<name>, and the message comes from Display. #[miniextendr(serde_error(..))] exists only to carry options (tag, prefix, skip, rename, the as_* field types, or a shared shape); the bare flag is a compile error, since it would switch nothing on.

#[derive(Debug, thiserror::Error, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EngineError {
    #[error("field `{field}` is missing")]
    MissingField { field: String },
    #[error("{value} exceeds the maximum {max}")]
    OutOfRange { value: f64, max: f64 },
}

#[miniextendr]
pub fn check(value: f64) -> Result<f64, EngineError> {
    if value > 100.0 { return Err(EngineError::OutOfRange { value, max: 100.0 }); }
    Ok(value)
}
e <- tryCatch(check(150), error = function(e) e)
class(e)
# [1] "mypkg_error_out_of_range" "mypkg_error" "rust_error" "simpleError" "error" "condition"
e$value; e$max
# [1] 150
# [1] 100
  • The family class defaults to <crate>_error, from the Rust crate name at compile time. Override it with serde_error(prefix = "engine").
  • Internally tagged enums (#[serde(tag = "kind")]) have the tag field consumed as the variant, so it never collides with the framework’s own e$kind ("result_err"). Name a different tag with serde_error(tag = "type"). Externally tagged enums (serde’s default) report the variant name verbatim (mypkg_error_OutOfRange; add #[serde(rename_all = "snake_case")] for snake_case).
  • Struct variants contribute their fields. A newtype variant with a struct payload contributes that struct’s fields; any other newtype or tuple payload lands under e$value. Unit variants carry classes only. A value that serializes without variant information (a plain struct, a string) gets just the family class. Result<T, String> and Result<T, &str> therefore raise c("mypkg_error", "rust_error", …) with the text as the message, no data.
  • Values follow the RValue mapping (RValue::from_serde, see SERDE_R.md): scalars, non-empty homogeneous Vec<T> as atomic vectors, nested structs as named lists, None as NULL. An empty Vec<T> arrives as list(), since serde gives no element type for an empty sequence, so e$columns is list() when empty and a character vector otherwise; a Vec<Option<String>> with a None is a list with a NULL hole. Give such a field its R type with #[serde(serialize_with = "miniextendr_api::serde::as_character")] (or as_integer, as_double, as_logical): it is then always that vector type, character(0) when empty, a None element NA, the same field error!(data = ...) gives for the data (#2164). Other serializers see the field unchanged. For an error type you can’t annotate (its crate doesn’t depend on miniextendr), choose the same types at the condition boundary, once for the type with serde_error_shape!(EngineError = ENGINE), see Field types and shared shapes. Deriving RConditionData (above) instead converts each field with RValue::from. The reserved names above apply to the payload fields; a clash raises a rust_error describing it, exactly as for data(), with the exceptions below.
  • serde_error(..) needs a Result return type and cannot be combined with unwrap_in_r; both are compile errors, as is the bare serde_error flag.

🔗Payload fields named message

A wrapped parser or foreign error is usually Variant { message: String }, and renaming that field at the source changes a serialization other consumers read. Three rules keep the zero-boilerplate path open for that shape:

#[derive(Debug, thiserror::Error, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ParserError {
    #[error("line {line}: {message}")]
    Parse { message: String, line: u32 },
    #[error("{message}")]
    Wrapped { message: String },
}

#[miniextendr(serde_error(skip("message")))]
pub fn parse(text: String) -> Result<f64, ParserError> { /* ... */ }

#[miniextendr(serde_error(rename(message = "detail")))]
pub fn parse_keep(text: String) -> Result<f64, ParserError> { /* ... */ }
  • serde_error(skip("message", ...)) drops the named payload fields before the reserved-name check. Parse reaches R as mypkg_error_parse with e$line and the Display text as conditionMessage(e); the wrapped text survives only where Display includes it.
  • serde_error(rename(message = "detail", ...)) splices the field under the new name (e$detail). The source may be an identifier or, for a serde-renamed field whose name is not one, a string literal: rename("kebab-name" = "kebab"). A target may not be message, call or kind, and two pairs may not share a target; both are compile errors. A target the variant already carries (rename(message = "line") on Parse) would give the condition two line fields of which e$line reads only the first, so it raises a rust_error naming the field and the rename at raise time (#1459).
  • With no option, a message field whose value is exactly the Display text is dropped as redundant with the condition’s own message slot. Wrapped above therefore needs nothing; Parse (whose Display adds the line) still raises the reserved-name error unless skipped or renamed.

Both options name the field as it serializes and are a no-op for variants that lack it, so one attribute covers an enum whose variants differ. The macro cannot see the error type’s fields at expansion time; only the option grammar is checked there. A field named call or kind has no equality rule and must be skipped or renamed.

  • Choose RConditionData when the message or class vector needs to differ from the serde shape (it takes precedence, unless the R-facing crate declares a shape for the type; see below); the serde path is the zero-boilerplate default for enums that are already serde-tagged.

🔗Field types and shared shapes

A payload field’s R type follows its serialization, so an empty Vec<String> arrives as list() and a u32 as an integer. The error type can fix that itself with #[serde(serialize_with = "miniextendr_api::serde::as_character")] (above), but not when its crate can’t depend on miniextendr, or when its Serialize is hand-written for other consumers. The R-facing crate then picks the types at the condition boundary, once for the error type:

use miniextendr_api::condition::SerdeErrorShape;

pub const ENGINE: SerdeErrorShape = SerdeErrorShape::new()
    .prefix("engine")
    .skip(&["message"])
    .as_character(&["rule", "column"])
    .as_integer(&["n"]);

miniextendr_api::serde_error_shape!(engine::Error = ENGINE);

#[miniextendr]
pub fn check(x: f64) -> Result<f64, engine::Error> { /* ... */ }

#[miniextendr]
pub fn rate(x: f64) -> Result<f64, engine::Error> { /* ... */ }

Every #[miniextendr] function and method of the crate that returns engine::Error raises with ENGINE, with or without a serde_error(..) attribute, so a function added later can’t give the error’s fields other types than its siblings by leaving a reference out.

  • as_character(..), as_integer(..), as_double(..), as_logical(..) give each named field exactly what the matching serde::as_* helper gives it: its usual serialization as one vector of that type, character(0) for an empty sequence, NA for a None (element). A value that doesn’t fit (a string under as_integer, an integer outside R’s range) raises a rust_error naming the field and the option, with the original error’s message.
  • The shape holds the whole option set: tag, prefix, skip, rename and the four as_* lists, as const fn builder methods. The keys are the field names as the error serializes them, before any rename: .rename(&[("n", "count")]).as_integer(&["n"]) gives e$count as an integer. A field a variant lacks is a no-op for that variant. prefix defaults to the function’s crate, <crate>_error. SerdeErrorShape is also re-exported as miniextendr_api::serde::SerdeErrorShape and from the prelude, as is serde_error_shape!.
  • Contradictions inside a shape (a field in two as_* lists, a field skipped and typed or renamed, a reserved or repeated rename target, an option set twice) panic during const evaluation, so the const does not compile: error E0080, naming the field.
  • The right-hand side of serde_error_shape! is any constant expression of type SerdeErrorShape, so the builder chain can also be written inline. The error type must be Serialize + Display, which the declaration checks.
  • The declaration is a trait impl keyed by a marker type that miniextendr_init! defines at the crate root. That is what lets it name a type from another crate, including one that can’t depend on miniextendr, and it scopes the declaration to the declaring crate: another package returning the same type declares its own. A type gets one declaration per crate; a second is the compiler’s conflicting-implementations error (E0119). With the serde feature, a crate whose #[miniextendr] functions return a Result must call miniextendr_init! at its crate root, as every R package does; see MACRO_ERRORS.md.
  • A declared shape wins over an RConditionData impl of the type: the declaration is the R-facing crate’s choice for its own functions, where the impl is the error crate’s general one.
  • All of this runs only when the function returns Err: the shape is plain data the Err arm reads, at no cost to a successful call.

A function overrides the declared shape for itself with options of its own, next to skip and rename, or with another shape named by path:

#[miniextendr(serde_error(as_double("n")))]
pub fn rate(x: f64) -> Result<f64, engine::Error> { /* ... */ }

#[miniextendr(serde_error(shape = STRICT, as_double("n")))]
pub fn strict_rate(x: f64) -> Result<f64, engine::Error> { /* ... */ }
  • Shapes apply in order: the one declared for the type, then shape = , then the function’s own options. For a field set by more than one, the later wins: rate gives e$n a double. A later skip drops a field an earlier shape types or renames, a later as_* or rename keeps a field an earlier shape skips, and a type and a new name compose. A later tag / prefix replaces an earlier one.
  • shape = takes a path resolved from the function’s module (STRICT, crate::errors::STRICT), or on a method Self::SHAPE for an associated const of the impl’s type; a misspelt one is a compile error.
  • The function’s options work the same with no shape at all: serde_error(as_character("rule", "column"), as_integer("n")). A field takes one as_* option and can’t be both skipped and typed; both are compile errors, every conflict reported at once.

🔗Catching a condition in Rust: catch_condition

condition::catch_condition(f) runs f and returns a condition raised inside it as Err(RCondition) instead of letting it reach R. Use it when a body turns another function’s failure into its own result: a refusal that names the cause, a fallback, a retry.

use miniextendr_api::condition::{RCondition, catch_condition};
use miniextendr_api::{miniextendr, rust_error};

#[miniextendr]
pub fn restore(path: &str) -> Restored {
    match catch_condition(|| rebuild(path)) {
        Ok(object) => object,
        Err(RCondition::Error { message, class, .. }) => rust_error!(
            class = "pkg_error_restore",
            data = ("cause_class", class.join(" ")),
            "cannot restore {path}: {message}"
        ),
        Err(other) => other.raise(),
    }
}
Raised inside fcatch_condition
error! / rust_error!, warning!, message!, condition!, RCondition::raise()Err, with the kind (the variant), classes, data and call choice
an argument or conversion error (arg_error!, RCondition::arg_error), an ExternalPtr type errorErr (RCondition::Conversion / Error)
an R error, interrupt or other R exit crossing the Rust frames (R code run through eval_with_handlers, with_r_unwind_protect, …)resumed: R carries on to where it was jumping
a plain panic!() or any other payloadresumed: it becomes an R error at the boundary, with its source location
  • An Err can be raised again with .raise() or queued with .defer().
  • To get an R error back as a value, evaluate the R code with try_eval_with_handlers (or RCall::try_eval_with_handlers), which returns Err(REvalError); REvalError::reraise_class gives the classes to raise it again as your own (next section).
  • Deferred conditions queued inside f stay queued; capture takes them.
  • A caught raise still runs the panic hook: with MINIEXTENDR_BACKTRACE=true it prints the panic to stderr.

🔗Raising a caught R error as your own

RCall::eval, eval_base and r_eval_str catch an R error as expression::REvalError, and so do RCall::try_eval_with_handlers and expression::try_eval_with_handlers, which leave the call’s warnings and messages to the caller’s handlers (QUOTED_ARGUMENTS.md). REvalError holds R’s condition: message() (R’s conditionMessage(), without the Error in <call> : prefix and the Calls: line R prints), call(), classes() and the condition object (EXPRESSION_EVAL.md). Three ways raise it again from a #[miniextendr] function, each keeping R’s message as the message:

use miniextendr_api::expression::{RCall, REvalError};
use miniextendr_api::condition::RError;
use miniextendr_api::{miniextendr, rust_error, SEXP};

// 1. The package's class first, the caught classes after it.
#[miniextendr]
pub fn summarise(df: SEXP) -> SEXP {
    match unsafe { RCall::new("summary").arg(df).eval_base() } {
        Ok(value) => value,
        Err(e) => rust_error!(class = e.reraise_class("pkg_summary_error"), "{e}"),
    }
}

// 2. Returned as is: the caught classes, then the `rust_error` layers.
#[miniextendr]
pub fn summarise_plain(df: SEXP) -> Result<SEXP, REvalError> {
    unsafe { RCall::new("summary").arg(df).eval_base() }
}

// 3. Through `RError`, for data fields or to carry it off R's main thread.
#[miniextendr]
pub fn summarise_with_data(df: SEXP) -> Result<SEXP, RError> {
    unsafe { RCall::new("summary").arg(df).eval_base() }.map_err(|e| {
        let class = e.reraise_class("pkg_summary_error");
        RError::from(e).class(class).data("step", "summary")
    })
}

For an error R raised as errorCondition("…", class = "vctrs_error_x"), the first form gives

e <- tryCatch(summarise(x), error = function(e) e)
class(e)
# [1] "pkg_summary_error" "vctrs_error_x" "rust_error" "simpleError" "error" "condition"

reraise_class(own) puts own (one class or several, most specific first) ahead of the caught specific_classes(): R’s classes without the trailing rust_error, simpleError, error and condition layers, which the macro adds back once. A plain stop("…") has no specific class, so the result is c(own, "rust_error", "simpleError", "error", "condition"). A handler for the package’s class, or for the class R raised, catches it either way.

The raised condition’s call is the #[miniextendr] function’s own, as for any condition the function raises; e.call() is the call R recorded, there for a message that wants it. Fields of the caught condition other than its message (rlang’s body or parent, say) are not copied; read them from e.condition() and attach the ones a handler needs with data =. A data = value can’t be an R object, so the caught R condition itself can’t be kept as the new one’s parent yet (#1874); a condition raised in Rust can, with with_parent.

To raise the caught condition itself, unchanged (R’s classes and fields, no rust_error class, R’s call), use unsafe { e.reraise() } inside the #[miniextendr] call: it signals the condition again through base stop(cond), and the Rust frames drop their values on the way out (#2163, QUOTED_ARGUMENTS.md).

REvalError is !Send: dropping it releases the condition’s root, which must happen on R’s main thread. Convert it to an RError (the third form), or keep the String from message(), to carry the failure to a worker thread.

🔗Chaining a cause: with_parent

RCondition::with_parent(cause) attaches another RCondition as the cause, the way rlang::abort(parent = ) chains conditions. R gets the cause as the field e$parent: a condition object with its own classes, message and fields, built like the raised condition but never signalled. A cause can have its own parent, so the chain is e$parent$parent and so on.

use miniextendr_api::condition::{catch_condition, ConditionCall, RCondition};
use miniextendr_api::{error, miniextendr};

fn parse_line(line: &str) -> i32 {
    line.trim().parse().unwrap_or_else(|_| {
        error!(class = "pkg_error_parse", data = ("line", line), "not a number: {line}")
    })
}

#[miniextendr]
pub fn load_setting(line: &str) -> i32 {
    match catch_condition(|| parse_line(line)) {
        Ok(value) => value,
        Err(cause) => RCondition::Error {
            message: "could not load the setting".into(),
            class: vec!["pkg_error_load".into()],
            data: None,
            call: ConditionCall::Inherit,
        }
        .with_parent(cause)
        .raise(),
    }
}
e <- tryCatch(load_setting("x"), pkg_error_load = function(e) e)
conditionMessage(e)
# [1] "could not load the setting"
class(e$parent)
# [1] "pkg_error_parse" "rust_error" "simpleError" "error" "condition"
e$parent$line
# [1] "x"
  • Classes stay apart. The cause’s classes are not added to the raised condition’s, so a handler for pkg_error_parse does not catch load_setting()’s error. Code that cares about the cause tests it: inherits(e$parent, "pkg_error_parse").
  • Call. The cause reports the same call as the raised condition, or none when the cause was raised with call = none.
  • Any kind. The raised condition and its cause can each be an error, warning, message or plain condition.
  • One parent. A second with_parent replaces the first, and the cause replaces a data field the condition names parent.
  • Transport. The cause travels in the reserved data field .mx_parent (see Reserved names), so it goes wherever the condition’s other data fields go.
  • Only Rust conditions. The cause is an RCondition. An R condition caught as REvalError can’t be a parent yet (#1874).

A #[externalptr(rebuild = ..)] hook’s error reaches R this way: a failed rebuild is refused with the hook’s error as e$parent (EXTERNALPTR.md).

🔗Conditions without a call

R’s warning(..., call. = FALSE) and stop(..., call. = FALSE) raise a condition that names no call. That suits a condition about the data rather than about the call: a warning that row 5 overrides an earlier row says nothing about read_overrides(path), and repeating the call on every such warning is noise. The same opt-out exists per condition:

FormSpelling
Macroserror!(call = none, …) / rust_error!, warning!, condition! / rust_condition!, defer_warning!, defer_condition!
RErrorRError::new("…").without_call()
Derive#[condition(call = none)] on a #[derive(RCondition)] type (every variant) or on one variant
Hand-written RConditionDatafn call(&self) -> ConditionCall { ConditionCall::None }

In the macros call = none comes first, before class = … and data = …. none is the only value: the default (the wrapper’s call) needs no spelling, and any other value is a compile error, in the macros and in the derive.

use miniextendr_api::RCondition;
use miniextendr_api::{defer_warning, miniextendr};

#[derive(Debug, RCondition)]
#[condition(class = "pkg_override")]
pub enum OverrideWarning {
    #[condition(call = none, message = "row {row} overrides an earlier row for profile {profile}")]
    Overridden { row: i32, profile: i32 },
    #[condition(message = "{n} rows settled")]
    Settled { n: i32 },
}

#[miniextendr]
pub fn read_overrides(n: i32) -> i32 {
    defer_warning(OverrideWarning::Overridden { row: 5, profile: 3 });
    defer_warning(OverrideWarning::Settled { n });
    n
}
read_overrides(3L)
# [1] 3
# Warning messages:
# 1: row 5 overrides an earlier row for profile 3
# 2: In read_overrides(3L) : 3 rows settled

w <- tryCatch(read_overrides(3L), pkg_override_overridden = function(w) w)
conditionCall(w)
# NULL
w$row
# [1] 5
w
# <pkg_override_overridden: row 5 overrides an earlier row for profile 3>

Only the call changes. Classes, data fields, kind and handler matching (tryCatch(pkg_override = ), rust_warning = , withCallingHandlers with muffleWarning) are the same as with the call; an error prints as Error: <message> instead of Error in f(...) : <message>.

How it travels. The marker is FALSE in the call slot of the tagged condition value (no new slot, no new kind). The R helper reads it as “no call”:

.call <- if (isFALSE(.val$call)) NULL else if (is.null(.val$call)) .call_default else .val$call

Every transport carries it:

  • immediate macros and Result errors, through the generated wrapper;
  • deferred conditions, signalled from Rust with the same helper;
  • #[miniextendr(worker)] bodies: the worker wrapper catches the condition on the worker and builds its tagged value on the main thread, so an immediate warning! / error! there keeps its kind, classes, data and call choice;
  • trait-ABI vtable shims: the marker survives the re-panic into the consumer, so a producer’s call-less warning is call-less in the consumer’s wrapper too;
  • with_r_unwind_protect_or_raise and ALTREP RUnwind guards, where a call-less error drops the guard’s call argument.

Messages. A message from miniextendr carries no call (conditionCall(m) is NULL), so message! has no call = form (it is a compile error), and defer_message!(call = none, …) is accepted for the shared grammar and changes nothing.

Argument-conversion errors keep the call. They are about an argument of the call, and the R-side argument checks they mirror name the call too, so #[condition(call = none)] on a type used as a TryFromSexp::Error has no effect on a failed conversion. The one exception is a function that reports no call at all, below.

🔗A function without a call: call = none

The per-condition opt-out is a decision per warning! or per variant. A function whose conditions are all about the data makes it once: #[miniextendr(call = none)] drops the call from every condition the function raises, however it was reached, the argument errors included (#1851):

#[miniextendr(call = none)]
pub fn no_call_verb(x: i32, #[miniextendr(match_arg)] mode: Mode) -> Result<i32, String> {
    if x < 0 {
        return Err(format!("x must be non-negative, got {x}"));
    }
    if x == 0 {
        defer_warning!("x is zero");
    }
    Ok(x)
}
no_call_verb(-1L, "Fast")
# Error: x must be non-negative, got -1
no_call_verb(1.5, "Fast")
# Error: 'x' must be integer
no_call_verb(0L, "Fast")
# Warning message:
# x is zero

The wrapper passes .call = FALSE, the marker above, into .Call(), so an Err, a panic, a classed error! and a deferred warning or message all arrive call-less through the transport that already exists, and it hands NULL to its own checks (type and length guards, no_na, inherits, not_inherits, match_arg / choices), which a failed Rust conversion shares. The option applies to standalone functions, S3 methods (s3(generic = ..., class = ...)) included; it has no marker type, so a Call / CallerCall parameter next to it is a compile error, as are call_arg and an extern "C-unwind" function. A crate default, call_attribution = "none" in Cargo.toml, applies it to every free function, and a per-item call = wrapper restores the call on one. Details: CALL_ATTRIBUTION.md.

🔗Argument errors from a body

The generated wrapper’s argument checks (match_arg, inherits and its when(...) hints, not_inherits, no_na, the type preconditions) and a failed argument conversion all raise one condition: kind = "conversion", e$param naming the argument, and the crate’s conversion_error_class before the rust_error layering (ERROR_HANDLING.md). arg_error! raises that condition from a function body, for a check the body makes itself: an argument handed on to another function’s body (which skips that function’s wrapper and its checks), or a rule that depends on another argument (#1740).

use miniextendr_api::{arg_error, miniextendr};

#[miniextendr]
pub fn in_metres(x: f64, unit: &str) -> f64 {
    match unit {
        "m" => x,
        "cm" => x / 100.0,
        _ => arg_error!(param = "unit", "'unit' should be one of \"m\", \"cm\""),
    }
}
e <- tryCatch(in_metres(1, "km"), error = identity)
class(e)
# [1] "pkg_error_argument" "pkg_error" "rust_error" "simpleError" "error" "condition"
e$kind
# [1] "conversion"
e$param
# [1] "unit"
conditionCall(e)
# in_metres(1, "km")

(The first two classes are the crate’s conversion_error_class; without the key the vector starts at rust_error.) The condition is the one the wrapper’s own check raises: same classes, same fields (message, call, kind, param) and the same call, which is the wrapper’s call as written or, under #[miniextendr(call = caller)], its caller’s (under call_arg, the call passed as .call; for an S3 method, the generic’s call; under call = none, no call). One handler (tryCatch(pkg_error_argument = …)) therefore catches the wrapper’s checks and the body’s alike.

The ... check of dots = empty | forward(allow = [..]) raises this argument error too, with param = "...", the class miniextendr_error_unused_argument (after the attribute’s own class) in front of the crate’s, and two more fields: argument (each refused argument, ..<i> for an unnamed one) and suggestion (the name each one most likely meant, or NA) (DOTS_TYPED_LIST.md).

  • Grammar: arg_error!([call = none,] param = <expr>, <format>, <args>…). param is the argument’s R name (anything Into<String>); a leading call = none raises it without a call, as for the other macros. There is no class = or data =: the classes are the crate’s and the only field is param. A call after param, a missing param, class = and data = are compile errors.
  • Control flow: it diverges like error! (the same panic_any transport), so it fits any expression position. Conditions queued with defer_warning! & co. are signalled before it, as before any error.
  • Without the macro: ArgError::new(param, message).raise() raises the same condition from a value. match_arg_param returns one for a choice argument the body matches itself (match_arg_param_with_default for one forwarded from a default = "..." parameter) (ENUMS_AND_FACTORS.md). ArgError is a plain value and does not implement RConditionData: returned as a body’s Err it takes the Result path (kind = "result_err"), so raise it with .raise().
  • Where it works: immediate calls, #[miniextendr(worker)] bodies (the worker wrapper builds the condition on the main thread) and trait-ABI calls, where the consumer package’s wrapper raises it with that crate’s classes.

How the crate’s classes get there. A macro_rules! macro cannot read [package.metadata.miniextendr], so the tagged condition value carries the marker class .miniextendr_conversion_error_class in its class slot (no new slot, no new kind). The wrapper file’s .miniextendr_raise_condition replaces the marker with the value of the binding of that name, the classes its own checks use (NULL without the key). The generated no_na check after a conversion builds the same condition with arg_check_condition_value, which receives the classes the #[miniextendr] macro read at expansion time; the two are the generated-code and hand-written spellings of one condition.

A raising guard without a generated R wrapper (an ALTREP r_unwind callback, with_r_unwind_protect_or_raise) resolves the marker in Rust, against the classes miniextendr_init! registered for the package (on wasm32, the copy the wasm_registry.rs snapshot carries), and raises the same condition: the crate classes, kind = "conversion", e$param and the rust_error layering (#1768). A connection callback returns its fallback value, as for any panic.

🔗Trait-ABI and ALTREP error class layering

Cross-package trait method panics and ALTREP r_unwind callback panics do receive rust_* class layering, even though there is no R wrapper to inspect a tagged SEXP. Two different mechanisms cover the two contexts:

  • Trait-ABI shims: the vtable shim returns a tagged SEXP on panic; the generated View method wrapper inspects the result and re-panics with the reconstructed [RCondition]. The consumer’s outer with_r_unwind_protect guard (every #[miniextendr] fn has one) catches the re-panic and produces the tagged SEXP for the consumer’s R wrapper. End-to-end behavior is identical to a same-package call: tryCatch(rust_error = h, ...) matches; user classes from error!(class = "...", ...) match before rust_error. Structured data = fields (tagged SEXP slot [4]) are also preserved across the re-panic boundary — e$field_name is accessible in R handlers even when the error crossed a package boundary (see #996 path-1).

  • ALTREP r_unwind callbacks (and with_r_unwind_protect_or_raise): the guard builds the tagged value a wrapper would receive and raises it with the wrappers’ own .miniextendr_raise_condition helper (no R wrapper required), so the condition is the one a wrapper raises for the same payload (#1768): tryCatch(rust_error = h, ...) matches, user classes match before rust_error, e$kind is "error" for error!(), "conversion" for arg_error!() and "panic" for a panic, and structured data = fields follow message, call and kind, so e$field_name works from ALTREP-raised errors too (see #996 path-2). The one difference is the call: the guard reports the call it was given, NULL in an ALTREP callback.

🔗Remaining limitations

Two narrow cases still degrade:

  • warning!() / message!() / condition!() from an ALTREP r_unwind callback or a custom connection’s I/O callback. There is no mechanism to suspend execution to deliver a non-fatal signal from inside R’s vector-dispatch or connection machinery. These produce an R error (kind = "panic", no user classes or data) with the message: “warning!/message!/condition! from an ALTREP or connection callback context cannot be raised as non-fatal signals; use error!() instead”.

  • A trait View method (view.method()) called from Rust code that is not wrapped in with_r_unwind_protect (e.g., a manual call from a test harness or init callback). The re-panic from the View has no outer guard to catch it, so the worker thread’s catch_unwind boundary converts it to an R error without rust_* class layering. In practice, every #[miniextendr] fn already provides the outer guard, so this only affects unusual call sites.

Functions that explicitly opt out via #[miniextendr(unwrap_in_r)] deliver Result<T, E> to R as a list with an $error slot rather than treating Err as a Rust-origin failure — Err never traverses the condition pipeline.

🔗AsRError — wrapping std::error::Error

For functions that return Result<T, E> where E: std::error::Error, AsRError<E> wraps the error and formats its full cause chain into the message:

use miniextendr_api::condition::AsRError;
use miniextendr_api::miniextendr;

#[miniextendr]
fn parse_number(s: &str) -> Result<i32, AsRError<std::num::ParseIntError>> {
    s.parse::<i32>().map_err(AsRError)
}
tryCatch(parse_number("abc"), error = function(e) e$message)
# [1] "invalid digit found in string"

For errors with a source chain, all causes appear in the message separated by \n caused by: ....