Expand description
Serialize complex objects into flat byte buffers and transfer them over FFI.
Types like String, Vec<T>, HashMap<K, V>, and structs containing them cannot be
passed directly over an FFI boundary. Their in-memory form is a small header (pointer,
length, capacity) into a Rust-managed heap allocation — the foreign side cannot read,
resize, or free that memory. Wire<T> solves this by serializing the value into a flat
byte buffer on one side and deserializing it on the other.
§Examples
§Accepting a complex argument
use interoptopus::ffi;
use interoptopus::wire::Wire;
use std::collections::HashMap;
#[ffi]
pub fn lookup(mut map: Wire<HashMap<String, String>>) -> u32 {
let map = map.unwire();
map.len() as u32
}§Returning a complex value
use interoptopus::ffi;
use interoptopus::wire::Wire;
#[ffi]
pub fn greeting() -> Wire<String> {
Wire::from("hello".to_string())
}§Structs containing non-FFI types
Any #[ffi] struct whose fields are not all repr(C) can be wrapped in
Wire<T>. The proc macro generates matching serialization code on both
sides.
use interoptopus::ffi;
use interoptopus::wire::Wire;
#[ffi]
pub struct UserProfile {
pub name: String,
pub tags: Vec<String>,
}
#[ffi]
pub fn accept_profile(mut profile: Wire<UserProfile>) {
let profile = profile.unwire();
println!("{}: {:?}", profile.name, profile.tags);
}§Deeply nested types
Wire<T> handles arbitrarily nested structures, including Vec, HashMap,
and Option at any depth:
use interoptopus::ffi;
use interoptopus::wire::Wire;
use std::collections::HashMap;
#[ffi]
pub struct Inner { pub score: u32 }
#[ffi]
pub struct Outer {
pub items: HashMap<u32, Vec<Inner>>,
}
#[ffi]
pub fn process(mut data: Wire<Outer>) -> u32 {
let data = data.unwire();
data.items.values().flatten().map(|i| i.score).sum()
}§Registering the helpers
Every crate that uses Wire<T> must call builtins_wire!
in its inventory so the create/destroy helpers are available to the foreign side:
use interoptopus::ffi;
use interoptopus::wire::Wire;
use interoptopus::{builtins_wire, function};
use interoptopus::inventory::RustInventory;
#[ffi]
pub fn greeting() -> Wire<String> {
Wire::from("hello".to_string())
}
pub fn ffi_inventory() -> RustInventory {
RustInventory::new()
.register(function!(greeting))
.register(builtins_wire!())
.validate()
}§Wire vs. Protobuf
The natural alternative to Wire<T> for passing complex ‘variably-sized’ types over FFI is
Protocol Buffers. Protobuf works, but it comes with significant
friction: you need to maintain .proto schema files alongside your Rust types, install and
run an external code generator as part of your build, integrate that generator into both the Rust
and the foreign-language project, and keep all three in sync whenever a type changes.
The result is a more complex project setup with more moving parts — and you still have to
wire the generated types into your FFI layer by hand.
Wire<T> eliminates all of that. Types are defined once in Rust with #[ffi], and both
the serialization logic and the foreign-language deserialization code are generated
automatically as part of the normal interoptopus build. There are no .proto files, no
external tools, and no schema drift.
Beyond ergonomics, Wire<T> is in most cases also faster. Because both sides share the
exact same compiled type layout, there is no field-tag overhead, no varint encoding, and
no dynamic dispatch — just a straight sequential read/write of the exact bytes needed.
In our benchmarks, Wire<T> usually outperformed Protobuf by roughly 20–200%
depending on the payload shape.

Note, in the benchmarks above, Protobuf was given a slight advantage over Wire<T> by not having to
FFI allocate. This made Protobuf’s performance look slightly better, but would make it unsuitable for
async use.
§Under the Hood
A Wire<T> is essentially a serialized buffer that is safe to pass through
FFI boundaries.
§Rust -> Foreign
- Serialize —
Wire::from(orWire::try_from) serializes the value into a new Rust-allocated buffer. - Transfer — the
Wire<T>is returned from an#[ffi]function; as arepr(C)struct it crosses the FFI boundary by value. - Deserialize — the foreign side (e.g., C#) reads the buffer bytes and reconstructs the managed type.
- Free — the foreign side calls
Dispose()or similar on the wire object, which invokesinteroptopus_wire_destroy(emitted bybuiltins_wire!) to drop the Rust-allocated buffer.
§Foreign -> Rust
- Allocate — the generated
WireOf*.From(value)helper callsinteroptopus_wire_create(emitted bybuiltins_wire!) so that Rust allocates the buffer; the foreign side never allocates directly. - Serialize — the value is serialized into that Rust-allocated buffer.
- Transfer — the
Wire<T>is passed into an#[ffi]function. Rust receives ownership. - Deserialize —
Wire::unwireorWire::try_unwirereadsTfrom the buffer. - Free — Rust drops the
Wire<T>when the function returns, freeing the buffer.
§Wire format
All values are written in little-endian byte order, sequentially, with no padding or alignment between fields:
| Type | Format |
|---|---|
u8..u64, i8..i64, f32, f64 | Fixed-size little-endian bytes |
usize / isize | Platform-width little-endian (8 bytes on 64-bit) |
bool | 1 byte (0x00 = false, non-zero = true) |
String | u32 byte-length (LE), then UTF-8 bytes |
Vec<T> | u32 element count (LE), then each element serialized in order |
HashMap<K,V> | u32 entry count (LE), then each key followed by value |
(A, B, …) | Each element serialized in order |
| User structs | Each field serialized in declaration order |
The wire format is not self-describing, both sides must agree on the exact type layout.
Note: This section describes an internal implementation detail that may change between versions without notice. Do not rely on it for persistent storage or cross-version compatibility.
Structs§
- Serialization
Error - Error returned when a wire-format serialization or deserialization fails.
- Wire
- Wraps and transfers complex objects over FFI.