Type generation
Why type generation?
Declaring every type across an FFI boundary is painful. Complex types like nested enums, generics, and rich view models are awkward to expose directly through general-purpose FFI binding tools. And even when you can declare them, maintaining the declarations by hand as your app evolves is tedious and error-prone.
Crux sidesteps this problem by keeping the FFI surface as small as
possible. The entire core-shell interface is just three methods —
update, resolve, and view — and all data crosses the boundary as
serialized byte arrays (using bincode). The
shell doesn't need to know the Rust types at the FFI level at all.
BoltFFI gives Crux the bindings for that byte-oriented API, but it doesn't remove the need for generated shell types. Two constraints matter here:
- Shell types should be immutable value types. Rust-backed FFI objects can make ownership and mutation part of the UI boundary; immutability is still being worked through in boltffi#292.
- Shells need to connect view models to UI-native state mechanisms:
Swift
@Observable, KotlinStateFlow, TypeScript framework state such as ReactuseState, and C#INotifyPropertyChanged/ObservableObject. Those APIs expect native values or native observable wrappers, not Rust-backed objects.
Crux is still exploring where those responsibilities should sit, and
whether difficient
can reduce the payload over the wire by sending changes instead of
whole values. For now, type generation is the stable layer that gives
shells native value types while the FFI stays small.
That generated layer has a concrete job: the shell must serialize
events and deserialize effects and view models on its side of the
boundary. To do that, it needs equivalent type definitions in Swift,
Kotlin, TypeScript, or C#, along with the matching serialization code.
Type generation inspects your Rust types and generates those foreign
types and their bincode serialization implementations automatically.
How it works
Type generation uses the Facet crate for
zero-cost reflection. Types that derive the Facet trait can be
introspected at build time to discover their shape — fields, variants,
generic parameters. The
facet-generate crate
uses that reflection data to generate equivalent types (and their
serialization code) in Swift, Kotlin, TypeScript, and C#.
The process has three parts:
- Annotate your types — derive
Faceton types that cross the FFI boundary, and use#[effect(facet_typegen)]on yourEffectenum. - Add a codegen binary to your shared crate — a short
mainthat registers your app and generates the foreign code. - Run it — typically via a
just typegenrecipe as part of your build workflow.
Annotating your types
Events, ViewModel, and other data types
Types that the shell needs to know about should derive Facet (along
with Serialize and Deserialize for the FFI serialization). Here's
the counter example:
#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
#[repr(C)]
pub enum Event {
Increment,
Decrement,
Reset,
}
#[derive(Facet, Serialize, Deserialize, Clone, Default)]
pub struct ViewModel {
pub count: String,
}
Note the #[repr(C)] on the enum — this is required by Facet for
enums that cross the FFI boundary.
The Effect type
The Effect enum uses the #[effect(facet_typegen)] attribute, which
tells the #[effect] macro to generate the type registration code
that the codegen binary needs:
#[effect(facet_typegen)]
#[derive(Debug)]
pub enum Effect {
Render(RenderOperation),
}
The macro discovers the operation types carried by each variant (e.g.
RenderOperation) and registers them for type generation
automatically.
Skipping and opaque types
Not all event variants need to cross the FFI boundary. Internal
events (ones the shell never sends) can be excluded from the generated
output with #[facet(skip)]:
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum Event {
// events from the shell
Get,
Increment,
Decrement,
Random,
StartWatch,
// events local to the core
#[serde(skip)]
#[facet(skip)]
Set(#[facet(opaque)] crux_http::Result<crux_http::Response<Count>>),
#[serde(skip)]
#[facet(skip)]
Update(Count),
#[serde(skip)]
#[facet(skip)]
UpdateBy(isize),
}
In this example, Set, Update, and UpdateBy are internal events
— the shell never creates them, so they're skipped.
However, Facet must still be derivable on the entire type,
including skipped variants. If a skipped variant contains a field
whose type doesn't implement Facet (like crux_http::Result<...>),
you need to mark that field with #[facet(opaque)] so the derive
succeeds. That's why Set has both #[facet(skip)] on the variant
and #[facet(opaque)] on its field.
The codegen binary
Each shared crate includes a small binary that drives the type generation. Here's the one from the counter example:
use std::path::PathBuf;
use anyhow::Result;
use clap::{Parser, ValueEnum};
use crux_core::type_generation::facet::{Config, TypeRegistry};
use log::info;
use shared::Counter;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Language {
Swift,
Kotlin,
Csharp,
Typescript,
}
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long, value_enum)]
language: Language,
#[arg(short, long)]
output_dir: PathBuf,
}
fn main() -> Result<()> {
pretty_env_logger::init();
let args = Args::parse();
let typegen_app = TypeRegistry::new().register_app::<Counter>()?.build()?;
let name = match args.language {
Language::Swift => "App",
Language::Kotlin => "com.crux.examples.counter",
Language::Csharp => "CounterApp.Shared",
Language::Typescript => "app",
};
let config = Config::builder(name, &args.output_dir).build();
match args.language {
Language::Swift => {
info!("Typegen for Swift");
typegen_app.swift(&config)?;
}
Language::Kotlin => {
info!("Typegen for Kotlin");
typegen_app.kotlin(&config)?;
}
Language::Csharp => {
info!("Typegen for C#");
typegen_app.csharp(&config)?;
}
Language::Typescript => {
info!("Typegen for TypeScript");
typegen_app.typescript(&config)?;
}
}
Ok(())
}
The key steps are:
TypeRegistry::new().register_app::<Counter>()?— discovers all types reachable from yourAppimplementation (events, effects, view model, and the operation types they reference)..build()?— produces aCodeGeneratorwith the full type graph.Config::builder(name, &output_dir)— configures the output. Thenameparameter is the package/module name (e.g."App"for Swift,"com.crux.examples.counter"for Kotlin,"app"for TypeScript,"CounterApp.Shared"for C#)..swift(&config)?/.kotlin(&config)?/.typescript(&config)?/.csharp(&config)?— generates the code, including the target-language serialization runtime forbincode.
BoltFFI binding generation is run separately by the shell build recipes with
boltffi pack .... The codegen binary is intentionally focused on Crux app
types.
Cargo.toml setup
The codegen binary needs a few additions to your shared/Cargo.toml.
Declare the binary, gated on a codegen feature:
[[bin]]
name = "codegen"
required-features = ["codegen"]
Enable facet_typegen in crux_core:
[features]
facet_typegen = ["crux_core/facet_typegen"]
And add facet as a dependency — all types that cross the FFI
boundary derive Facet:
[dependencies]
facet = { version = "=0.44", features = ["chrono"] }
Running type generation
Type generation is typically run via Just
recipes. Each shell runs the codegen binary and writes the output into
a generated/ directory inside itself. In the counter example, the
layout looks like this:
examples/counter/
├── shared/ # the Crux core
├── apple/
│ └── generated/ # Swift package "App"
├── Android/
│ └── generated/ # Kotlin package "com.crux.examples.counter"
├── web-react-router/
│ └── generated/
│ └── types/ # TypeScript package "app"
└── ...
The package names are set in codegen.rs via the Config::builder
call — see the codegen binary above.
Each shell's Justfile has a typegen recipe. For example, the Apple
shell runs:
RUST_LOG=info cargo run \
--package shared \
--bin codegen \
--features codegen,facet_typegen \
-- \
--language swift \
--output-dir generated
The --output-dir is relative to the shell directory where the recipe
runs — so the generated code lands right where the shell project can
reference it. The TypeScript shells use generated/types to keep the
types separate from the wasm package (which lives in generated/pkg).
The generated/ directories are gitignored and regenerated as part of
the build process. Each shell's build recipe depends on typegen.
What gets generated
For each target language, the codegen produces:
- Type definitions — enums, structs, and their serialization code,
matching the shape of your Rust types. For example,
Event,Effect,ViewModel, and any operation types. - Serialization runtime — Serde and
bincodeimplementations in the target language, so the shell can serialize events and deserialize effects and view models. - Helper extensions — like
Requests.swift, which provides convenience methods for working with effect requests.
For Swift, Kotlin, TypeScript, and C#, this typegen output sits beside the BoltFFI-generated binding package for the byte-oriented core API.