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 difficult or
impossible to represent directly in tools like UniFFI or
wasm-bindgen. 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.
But the shell does need to serialize events and deserialize effects and view models on its side of the boundary. For that, it needs equivalent type definitions in Swift, Kotlin, or TypeScript — along with the matching serialization code. This is what type generation provides: it inspects your Rust types and generates the corresponding 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, and TypeScript.
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)]
#[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 clap::{Parser, ValueEnum};
use crux_core::{
cli::{BindgenArgsBuilder, bindgen},
type_generation::facet::{Config, TypeRegistry},
};
use log::info;
use uniffi::deps::anyhow::Result;
use shared::Counter;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Language {
Swift,
Kotlin,
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::Typescript => "app",
};
let config = Config::builder(name, &args.output_dir)
.add_extensions()
.add_runtimes()
.build();
match args.language {
Language::Swift => {
info!("Typegen for Swift");
typegen_app.swift(&config)?;
}
Language::Kotlin => {
info!("Typegen for Kotlin");
typegen_app.kotlin(&config)?;
info!("Bindgen for Kotlin");
let bindgen_args = BindgenArgsBuilder::default()
.crate_name(env!("CARGO_PKG_NAME").to_string())
.kotlin(&args.output_dir)
.build()?;
bindgen(&bindgen_args)?;
}
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)..add_extensions()— includes helper code likeRequests.swiftthat makes it easier to work with the generated types..add_runtimes()— includes the serialization runtime (Serde and Bincode implementations in the target language)..swift(&config)?/.kotlin(&config)?/.typescript(&config)?— generates the code.
The binary also handles UniFFI binding generation for Kotlin (the
bindgen call), which produces the Kotlin bindings for the Rust FFI
layer.
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 = "=0.31"
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 Bincode implementations 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, the output is a Swift Package. For Kotlin, it's a set of source files alongside UniFFI bindings. For TypeScript, it's an npm package.