Types
When you design a Rust API for BoltFFI, you need to know what happens to your types on the other side. This page shows the mapping for each target language.
Quick Reference
i8→Int8i16→Int16i32→Int32i64→Int64u8→UInt8u16→UInt16u32→UInt32u64→UInt64f32→Floatf64→Doublebool→BoolString→StringVec<T>→[T]Option<T>→T?Result<T, E>→throwsPrimitives
Primitives are the foundation of BoltFFI’s performance. When you pass an i32 or f64 across the FFI boundary, the bytes copy directly. There’s no serialization, no intermediate buffer, no allocation. The value moves from one side to the other as raw memory.
This matters because every FFI call has overhead. If you’re making thousands of calls per second, or processing real-time data, that overhead adds up. By keeping your hot-path types primitive, you minimize the cost of each crossing.
Choose your integer size deliberately. i32 handles most application logic. i64 is necessary for timestamps, file sizes, or any value that might exceed 2 billion. u8 is the right choice for byte buffers, binary protocols, or any raw data manipulation. Using the smallest size that fits your data isn’t premature optimization here; it directly affects how much memory moves across the boundary.
For floating point, f64 is the default choice. It matches what most languages use for their default float type, and the extra precision rarely hurts. Use f32 when you’re interfacing with graphics APIs, audio processing, or other domains where single precision is the standard.
Strings
Strings are where the FFI cost becomes visible. Unlike primitives, strings require memory allocation and copying. Rust’s string data lives in Rust’s heap; the target language’s string lives in its own heap. Crossing the boundary means allocating new memory and copying bytes.
Both &str and String on the Rust side become owned strings in the target language. There’s no way to pass a reference that the target language can use without copying, because Rust’s memory model doesn’t extend across the FFI boundary. The target language needs its own copy that it can manage with its own garbage collector or reference counting.
The practical rule: use &str for parameters (you’re borrowing the caller’s data) and String for return values (you’re transferring ownership). This matches idiomatic Rust and works cleanly with BoltFFI’s generated code.
If string handling is your bottleneck, rethink the API. Instead of calling a function once per string in a loop, pass a batch of strings and process them all in one call. Or do the string-heavy work entirely on the Rust side and only return the final result.
Records
Records are value types: structs and enums marked with #[data]. When a record crosses the boundary, it gets copied. The target language receives its own copy of the data.
The cost depends on the contents: primitive-only records are fast, records with strings or collections require allocations.
#[data]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[data]
pub enum Status {
Pending,
Active,
Done,
}
See Records for nested structs, default values, enums with data, and performance characteristics.
Classes
Classes are reference types. The object lives in Rust, and the target language holds a handle to it. Use classes for stateful objects, resources, or anything with methods.
pub struct Counter { value: i32 }
#[export]
impl Counter {
pub fn new() -> Self { Counter { value: 0 } }
pub fn increment(&mut self) { self.value += 1; }
pub fn get(&self) -> i32 { self.value }
}
See Classes for constructors, methods, thread safety, and memory management.
Option
Option<T> in Rust becomes a nullable type in the target language. None becomes null or nil on the other side.
Result and errors
Result<T, E> becomes a throwing function. The Ok value is returned normally; the Err value becomes a thrown exception. The error type must be marked with #[error].
Collections
Collections let you pass multiple values in a single call. Vec<T> in Rust becomes a native array or list in the target language. Slices (&[T]) work the same way for input parameters.
The cost scales with the number of elements. A Vec<i32> with 1000 elements moves 1000 integers across the boundary. A Vec<User> with 1000 users moves 1000 structs, each with their own strings and fields. For large collections of complex types, this can become the dominant cost in your FFI call.
If performance matters, prefer collections of primitives over collections of complex types.
Basic collections
Nested collections
Vec<Vec<T>> and deeper nesting are supported. The deeper you nest, the more work it takes to move across the boundary.
Callbacks
Pass functions from the target language into Rust. Use impl Fn, impl FnMut, or impl FnOnce for simple callbacks.
#[export]
pub fn foreach_range(start: i32, end: i32, mut cb: impl FnMut(i32)) {
(start..end).for_each(|i| cb(i));
}
For callbacks with multiple methods or that need to be stored, use callback traits with #[export].
#[export]
pub trait Logger {
fn log(&self, message: &str);
fn flush(&self);
}
See Callbacks for closures, callback traits, and async callbacks.
What’s not supported
Some Rust types can’t cross the FFI boundary. This isn’t a limitation of BoltFFI specifically; these types don’t have meaningful representations in other languages, or would require runtime support that doesn’t exist.
-
Generic structs like
struct Wrapper<T>require monomorphization. Define concrete types likestruct StringWrapper { value: String }instead. -
Trait objects like
dyn Traitrely on Rust’s vtable mechanism. Use an enum with variants for each concrete type you need to support. -
Raw pointers are inherently unsafe. Handle pointer manipulation inside Rust and expose safe types at the boundary.
-
Non-static lifetimes like
&'a strcan’t be enforced across FFI. Return owned data (String) instead of borrowed references. -
HashSet doesn’t have a universal representation. Convert to
Vec<T>instead.