Building xberg, a document-intelligence engine with a Rust core, we needed the same engine callable from Python, TypeScript, Ruby, Go, PHP, and a dozen more. This is how we ship native SDKs in every one of them without hand-writing a single binding layer.
The polyglot binding tax
A Rust core is a good decision right up until you have to ship it to other languages. Then you inherit a second, quieter job: keep a PyO3 wrapper, a NAPI-RS addon, a Magnus extension, a cgo layer, and a handful more in lockstep with a core that moves every week.
That is where polyglot projects rot. The core adds a field, one language's wrapper lags, and nobody notices until a Ruby user files a bug that a Python user never sees. Multiply by sixteen languages and the binding layer becomes the most expensive code in the project, none of which is the actual product.
We did not want to write that code, let alone maintain it. So we generate it.
One config, every target
The tool is alef. It extracts the Rust API surface and emits language-native bindings, package scaffolding, type stubs, docs, and e2e tests from one alef.toml:
[workspace]
languages = ["python", "node", "ffi", "go"]
alef_version = "0.32.7"
[[crates]]
name = "sample_core"
sources = ["src/lib.rs"]
version_from = "Cargo.toml"
alef generate --format # bindings + type stubs
alef scaffold # package manifests + native build files
alef verify --exit-code # fail CI if generated files are stale
Each target gets an idiomatic package, not a lowest-common-denominator shim:
| Language | Backend |
|---|---|
| Python | PyO3 + type stubs |
| TypeScript / Node | NAPI-RS addon with .d.ts
|
| Ruby | Magnus extension |
| Go | cgo over a generated C FFI layer |
| PHP | native extension |
| Elixir / Gleam | Rustler NIF |
| Swift, Kotlin, C#, Dart, R, Zig, WASM | native bridge per platform |
Sixteen languages, eighteen targets (C FFI and a JNI shim round out the list). The Rust author writes Rust; the Python user gets a dataclass with type stubs, the Go user gets a cgo package, and neither knows the other exists.
The part that keeps them honest
Generating bindings is the easy half. The hard half is trust: how do you know all sixteen behave the same after you change the core?
Alef's answer is shared fixtures. You write one set of JSON fixtures describing inputs and expected outputs, and it generates a cross-language e2e suite plus standalone registry-mode test apps from them:
alef e2e # generate the cross-language suite from shared fixtures
alef test-apps # standalone apps that install the published package and run it
Now a change in the Rust core does not surface as a runtime surprise in one binding. It surfaces as the same failing assertion in every language at once, in CI, before release. The fixtures are the contract, and every SDK is held to it.
Generated files also carry an Alef hash, so alef verify --exit-code fails the build if someone hand-edits a generated binding or forgets to regenerate after a core change. The generated layer stays generated.
The honest tradeoffs
This is not free, and it is not magic.
- The core has to be designed for it. A stable, extractable API surface is a constraint you accept up front. Exotic Rust signatures do not always have a clean home in every target language, and the ones that matter live behind adapters rather than leaking into the core.
-
Idioms live in the adapter, not the core. Async wrappers, context managers, callbacks, error types: these are per-language, generated per-language, and configured in
[crates.<language>]sections. Shared semantics stay in the core so they cannot drift; ergonomics stay local. -
When codegen is not enough, you extend it. Domain-specific generation (an HTTP service API, a plugin registry) is an
Extensiontrait rather than a fork, shipped as a linked binary, a dynamic library, or template-only blocks inalef.toml.
We build xberg this way because the alternative, sixteen hand-maintained binding layers chasing one moving core, is exactly the kind of work that reads productive and quietly sinks a polyglot project. Generate the boring layer, test it against one fixture set, and spend the human time on the engine that is actually the product.
Alef is MIT and standalone: github.com/xberg-io/alef. If you maintain a Rust library that people keep asking for bindings to, it is worth a look.
Top comments (1)
Generating the bindings is the half that automates cleanly; the half that rots quietly is error semantics. A shared input/output fixture pins the happy path across all 16, but 'the Ruby user gets a bug the Python user never sees' usually lives in the failure path: a Rust
Result::Errshould surface as a raised exception in Python, a(nil, err)in Go, a rejected Promise in TS, and those are idiomatic differences, not one shape. Do the shared fixtures also assert the failure mapping per target, or just the success payload? Related: apanic!in the core is UB if it unwinds across the C FFI into cgo or the PHP extension, so is the generated boundary wrapping every entry point incatch_unwindand turning it into that language's error, or is panic-safety left to the core author? That's the pair I'd most want CI to hold, since it's exactly the stuff that only shows up in one language's bug tracker.