Exporting types
Ahoi does not convert Rust types to TypeScript.
That is a deliberate limit. Good converters already exist, they disagree about details, and re-implementing one inside ahoi would only make you choose again.
Split the job
Section titled “Split the job”Two kinds of type cross the bridge, and they are handled by different tools.
| What | Who handles it |
|---|---|
Your key and data types (Pier, Hail, Tell, Fruit) |
Your exporter: ts-rs, Tsify, Tsain, … |
| What each key returns | Ahoi’s #[derive(Rets)] |
No general-purpose converter can do the second one. It is not a property of a type, it is a property of a key.
Tsain is the one exception: it covers both rows at once. See With Tsain below.
With ts-rs
Section titled “With ts-rs”Derive TS on the types you want exported and add #[ts(export)]. Running
cargo test writes them.
#[derive(Rets, TS, Serialize, Deserialize)]#[ts(export)]pub enum Hail { #[ret(i32)] Count, #[ret(Option<i32>)] Item(usize),}export type Hail = "Count" | { Item: number };That is your key type. Rets.ts is the return map. You need both.
With Tsify
Section titled “With Tsify”Tsify works too, and it needs no export step. The declarations are embedded
in the .d.ts that wasm-pack build writes.
tsify = { version = "0.5", default-features = false, features = ["js"] }#[derive(Rets, Tsify, Serialize, Deserialize)]pub enum Hail { #[ret(i32)] Count, #[ret(Option<i32>)] Item(usize),}Import the key types from the wasm pkg itself:
import type { Hail } from "./pkg/my_wasm";The derive alone is enough. Skip #[tsify(into_wasm_abi)] and
#[tsify(from_wasm_abi)]: ahoi’s bridge converts values itself, and tsify
0.5 deprecates those attributes anyway.
One detail changes: if a ret map references one of your data types, its import must point at the pkg, since there are no per-type binding files.
TsFile::new() .import("Fruit", "../pkg/my_wasm") .with::<Hail>() .with::<Tell>() .export("./bindings/Rets.ts");With Tsain
Section titled “With Tsain”Tsain is different in kind: it is a converter and an exporter in one. Values cross the wire as positional arrays, and the export step writes the matching TypeScript.
The return type moves onto the key itself, as a brand:
#[derive(Tsain, Serialize, Deserialize)]pub enum Hail { #[tsain(brand(ret = i32))] Count, #[tsain(brand(ret = Option<i32>))] Item(usize),}No Rets derive. No TsFile. One generator writes everything:
#[test]fn generate() { tsain::TsScript::export("./bindings/Tsain.ts");}The file holds the types plus factory functions and getters. You need them; a positional array has no field names to read:
import { HailCount_, HailItem_ } from "./bindings/Tsain";
pier.hail(HailCount_()); // () => numberpier.hail(HailItem_(3)); // () => number | undefinedDrop the ret-map generics from createAhoi; the brands carry the types:
createAhoi<Pier, Hail, Tell>({ /* wasm exports */});Values must cross in the same array format, so pair this with the tsain
crate feature and TsainConverter. See Converter.
Types the maps reference
Section titled “Types the maps reference”A ret map names your types, but ahoi has no idea where your exporter put them:
ts-rs writes a file per type, Tsify puts them in the pkg, and a hand-written
.d.ts could be anywhere. So every type a #[ret(..)] mentions needs an
.import(..) line.
One import can carry several names, and aliases work:
TsFile::new() .import("Fruit, Basket", "./types") .import("Apple as Fruit", "./Apple")Forget one and the generated file names a type nothing declares, which shows up
later as a tsc error some distance from its cause.
unresolved lists the names nothing accounts for, so the generate test can
catch it at the source:
#[test]fn generate() { let file = TsFile::new() .import("Fruit", "./Fruit") .with::<Hail>() .with::<Tell>();
assert!(file.unresolved().is_empty(), "missing imports: {:?}", file.unresolved());
file.export("./bindings/Rets.ts");}Built-in names are excluded already, so string, number, Map, Record and
friends never need declaring.
The assertion is opt-in, and export never refuses a file on its own. Whether a
name resolves depends on your tsconfig, ambient declarations, and globals that
ahoi cannot see, and #[ret(ts = "...")] passes arbitrary TypeScript straight
through. ahoi is in no position to decide your file is wrong, so it reports and
leaves the call to you. Skip the assertion if your project has such types.
Wiring them together
Section titled “Wiring them together”The five generic parameters on createAhoi are, in order: the pier key, the
hail key, the tell key, the hail ret map, and the tell ret map.
import type { Pier } from "./bindings/Pier";import type { Hail } from "./bindings/Hail";import type { Tell } from "./bindings/Tell";import type { HailRets, TellRets } from "./bindings/Rets";
export const { PierProvider, usePier } = createAhoi< Pier, Hail, Tell, HailRets, TellRets>({ /* wasm exports */});The key types make pier.hail("Cont") a compile error. The ret maps make
pier.hail("Count") a number.
If your exporter brands keys
Section titled “If your exporter brands keys”Some setups attach the return type to the key itself rather than listing it in a map. Tsain above is one. Ahoi handles that too.
The JS side resolves a key’s return type in this order:
- a
retbrand on the key, if your converter produced one - the variant name, looked up in the
Retsmap - a fallback:
unknownfor a hail,undefinedfor a tell
So the bridge stays converter-agnostic. Use whichever style your exporter produces.
Keeping it fresh
Section titled “Keeping it fresh”Generation runs under cargo test, which means it is easy to forget in CI.
Two habits help:
- Commit the generated
bindings/directory, so a stale file shows up in a diff. - Run
cargo testbeforewasm-pack buildin your build script.
Values still have to physically cross the boundary. That is the converter.