External and remote types¶
Three related things, kept apart:
| Term | Meaning | Who does the work |
|---|---|---|
| External type | a UniFFI type defined in another UniFFI crate | the bindgen: import it from that crate's package |
| Remote type | a type from a crate that doesn't use UniFFI, like url::Url |
Rust only (custom_type! with remote, use_remote_type!, [Remote] in UDL) |
| Custom type | a type that crosses as a builtin | Rust, plus optionally [custom_types] in uniffi.toml |
Remote types need no generator support. By the time the bindgen sees one, it is an ordinary record, enum, object or custom type. If a remote type doesn't work, the problem is on the Rust side.
External types in the generator¶
Since UniFFI 0.29 there is no Type::External. A type from another crate is an ordinary
Type::Record, Type::Enum or Type::Object. Whether it is external is a query on the
ComponentInterface:
ci.is_external(ty)
ci.iter_local_types() // types this crate defines
ci.iter_external_types() // types this crate uses from others
The Types.kt templates render every local type, and include ExternalTypeTemplate.kt for every
external one.
Which package¶
Config::external_package_name(module_path, namespace) takes the crate name (the first segment of
the module_path, which is a full module path since UniFFI 0.31) and looks it up in
external_packages. That map is filled automatically in update_component_configs from all
crates in the generation run. Entries from uniffi.toml take precedence. The fallback
uniffi.<namespace> is only reachable in UDL mode.
What is emitted¶
| Output | For an external type Foo from package other |
|---|---|
commonMain |
import other.Foo |
jvmMain / androidMain / nativeMain |
import other.Foo, import other.FfiConverterTypeFoo, the error handler if it's an error, and typealias RustBufferFoo = uniffi.runtime.RustBuffer (plus …ByValue) |
| header | typedef RustBuffer RustBufferFoo; |
The aliases exist because UniFFI gives FFI signatures that carry another crate's type a distinct
RustBuffer type name. On the C side they are all the same struct. The aliases make the Kotlin
declarations match the headers. The suffix is the raw type name, not the Kotlin class name.
A new kind of external emission has to go into four templates:
generic/{common,android+jvm,native}/ExternalTypeTemplate.kt and generic/headers/Types.h.
Testing for externality
Don't check FfiType::RustBuffer(Some(_)) to find external types. UniFFI 0.32 attaches that
metadata to every record and enum. Compare external_meta.crate_name() with
ci.crate_name(), as KotlinCodeOracle does.
Initialisation order¶
Each namespace has its own lazy UniffiLib.INSTANCE. Its initialiser calls
<package>.uniffiEnsureInitialized() for every crate whose types it uses (initialization_fns()
in mod.rs), so that crate's callback vtables are registered first. See
Callbacks.
Multi-module builds¶
In a multi-module project, each Gradle module runs the
bindgen with --crate <own crate>. It generates Kotlin only for its own crate, and imports
everything else through the mechanism above. Because the other module is a Gradle dependency,
its classes are on the classpath. The result: one Kotlin class per Rust type, shared by all modules.
On the native side, each module builds its own library, and Rust links the shared crate into each of them:
graph TD
subgraph Kotlin["Kotlin: one copy per package"]
KC["rust_common"]
KA["module_a"]
KB["module_b"]
end
subgraph Native["Native libraries: rust_common's code is in each one"]
LC["librust_common"]
LA["libmodule_a + rust_common"]
LB["libmodule_b + rust_common"]
end
KC --> LC
KA --> LA
KB --> LB
What this means:
- Records and enums are serialised on every crossing, so it doesn't matter which library reads them.
- Objects are
Arcpointers.rust_common.TestObject's methods always go throughlibrust_common, even when the object was created bylibmodule_b. That works because every copy of the crate's code is identical and all libraries share the process allocator. The modules must be built from the same version of the shared crate. RustBuffers are allocated by the runtime's library (see Runtime) and freed by whichever library receives them. This relies on the shared allocator too.- Foreign callback vtables are per library, so they break: see Callbacks.
For Kotlin/Native all static libraries end up in one binary, with the shared crate's symbols in
several archives. Apple's linker keeps the first definition. lld (Linux) and the MinGW linker
refuse, which is why GenerateDefFileTask adds --allow-multiple-definition there.
generateBindingsForExternalCrates = true drops --crate, so a module generates Kotlin for every
UniFFI crate in its library. That is the right choice for a single module that wraps a third-party
UniFFI crate, but it produces duplicate classes as soon as two modules do it for the same crate.
Custom types¶
CustomCodeType (custom.rs) is thin: the type label is the custom type's name, and the converter
wraps the builtin's converter.
- Without a
[custom_types.<Name>]entry,common/CustomTypeTemplate.ktemitstypealias Name = <builtin Kotlin type>, andffi/CustomTypeTemplate.ktaliases the builtin's converter. - With an entry, the typealias points at
type_name, theimportsare added, and the converter applies thelift/lowerexpressions ({}is replaced with the value) around the builtin's converter. default()renders the builtin's default and wraps it in theliftexpression when a config entry exists.