Skip to content

TypeScript Bindings

When you configure a ClientGenerator::HttpTs or HttpTauriIpcSplit in your build script, Ontogen writes a TypeScript file (the bindings_path) containing every type your generated client surface references. Ontogen owns this file end-to-end — it rewrites it on each build.

Two emitters cooperate to populate it:

  • Schema-known emitter — entity types from src/schema/ plus the Create{Entity}Input / Update{Entity}Input DTOs Ontogen derives. Bounded mapping over FieldType; no AST walking. Always on.
  • Long-tail emitter — the ontogen-ts crate. A build-time AST walker that scans your src/ for every struct, enum, and type alias referenced (transitively) by custom API endpoint signatures, then emits TypeScript for the reachable closure.

Both emitters write to the same file: schema-known first, then long-tail appended.

ontogen-ts runs entirely inside build.rs. There is no separate compilation, no extra binary, no isolated CARGO_TARGET_DIR, no cargo invocation. The walker parses your .rs files with syn and emits TypeScript directly from the AST.

The pipeline:

  1. Scan. scan_src_dir(CARGO_MANIFEST_DIR/src) walks every .rs under src/ and builds a pool keyed by canonical TypePath. All module-level structs, enums, and type aliases land in the pool regardless of visibility. Keys are rooted at the crate the type was scanned from — the consuming crate’s own types under crate (crate::foo::bar::Baz), each extra root under that crate’s Cargo name. That rooting is what lets a sibling and the consumer both define lint::Severity without colliding.
  2. Root set. The schema-known emitter computes a long-tail name list from two sources, unioned and de-duped:
    • API surface — every type ident referenced by an endpoint’s params or return that isn’t an entity or generated DTO.
    • Schema-entity fields — every type ident referenced by an EntityDef.field’s type that isn’t itself a primitive, container, or another entity / generated DTO. This is what makes a field like interval_kind: Option<IntervalKind> on a schema entity pull IntervalKind into emission even if no API endpoint mentions it.
  3. Resolve. Each long-tail name is looked up in the pool: bare-ident match first, then any pool entry whose terminal segment matches.
  4. Emit. ontogen_ts::emit(roots, &pool, &config) walks the reachable closure of each root, renders the TS, and aggregates every error before returning.

If any root is unresolved or any reachable type fails to emit, the build panics with the full punch-list rather than emitting partial output. See Error model.

The phase-1 subset covers what’s needed for typical CRUD + custom-endpoint surfaces. Shapes outside this set hard-error; see Escape hatches for how to handle them.

Composite shapes:

  • Named structs with named fields (pub struct Foo { pub a: i32, pub b: String }).
  • C-style enums (enum Status { Active, Archived }) — render as TS union literals.
  • Externally-tagged enums where each variant’s tag is its ident (the serde default).

Container types (hardcoded):

  • Option<T> → T | null
  • Vec<T> / VecDeque<T> → T[] (both serialize as a JSON array)
  • HashMap<K, V> / BTreeMap<K, V> → Record<K, V> where K is String or an id-like primitive.
  • () → null (serde serializes the unit type as JSON null). Non-empty tuples are rejected — use a named struct.

Primitives:

  • bool → boolean
  • All integer types and f32 / f64 → number (see BigIntBehavior for 64-bit handling).
  • String / &str → string.

Smart-pointer transparency — peeled silently, the inner type is what gets emitted:

  • Box<T>, Rc<T>, Arc<T>, Cow<'_, T>, Pin<T>.

Type aliases — followed.

ontogen-ts reads #[serde(...)] attributes off your structs and enums so the emitted TypeScript matches the wire payload, not the Rust spelling.

  • #[serde(rename = "...")] on a field or variant.
  • #[serde(rename_all = "<mode>")] on a struct or enum.

All eight rename_all modes are supported (lowercase, UPPERCASE, PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE). The case-transform implementations are property-tested against serde_json::to_string so the TS output matches the wire payload exactly.

rename_all on an enum renames its variants, not the fields inside a struct variant — matching serde, which needs a separate rename_all_fields for that.

#[serde(skip)] on a field drops it from the TS rendering entirely.

A field with #[serde(default)] (bare or the default = "path" form) renders as TS-optional, since the wire payload may omit it:

pub struct Query {
pub term: String,
#[serde(default)]
pub limit: u32,
}
export type Query = { term: string; limit?: number };

Container-level #[serde(default)] on the struct itself defaults every field, exactly as serde does — a field doesn’t need its own attribute:

#[derive(Deserialize)]
#[serde(default)]
pub struct Query {
pub term: String, // → term?: string
pub limit: u32, // → limit?: number
}

Serde only accepts container-level default on structs, so this never applies to enum variants.

A flattened field’s keys are spliced into the parent object on the wire, so it renders as a TypeScript intersection:

pub struct Step {
pub id: String,
#[serde(flatten)]
pub meta: StepMeta,
}
export type Step = StepMeta & { id: string };

The flattened field’s own name never reaches the wire, so it never appears in the TS. If every field is flattened, the empty property object is dropped and the type is the bare intersection.

  • Split rename (rename(serialize = "...", deserialize = "...")) — hard error. Workaround: two fields plus a From impl.
  • The remaining shape-changing enum attrs: tag, content, untagged — hard error.

JavaScript number is a double-precision float; values above 2^53 lose precision. EmitConfig::bigint_behavior controls how Ontogen renders the 64-bit Rust integer types (u64, i64, usize, isize):

  • BigIntBehavior::Number (default) — TS number. Matches what most consumers expect; values above 2^53 silently truncate.
  • BigIntBehavior::BigInt — TS bigint. The wire format is JSON; your client code must use bigint literals.
  • BigIntBehavior::String — TS string. The wire payload is a JSON string; consumers parse it themselves.

The TS string literals Ontogen emits — most visibly enum variant wire names in string-literal unions — default to single quotes ('Red' | 'Green' | 'Blue'). EmitConfig::quote_style swaps the delimiter without otherwise changing the wire shape:

  • QuoteStyle::Single (default) — 'foo' | 'bar'. Matches eslint’s quotes: ['error', 'single'] style and preserves byte-identical output for pre-knob consumers.
  • QuoteStyle::Double — "foo" | "bar". Matches Prettier’s default and what the older specta-based emitter produced.
EmitConfig {
quote_style: ontogen_ts::QuoteStyle::Double,
..Default::default()
}

This is purely a generated-source style toggle — the JSON wire payload is unchanged. Pick whichever lines up with your project’s existing quote convention so the generated file blends into your formatter / lint setup without per-bump diff noise.

Some types are defined outside your crate (chrono’s DateTime, uuid’s Uuid, etc.) but appear in your API signatures. EmitConfig::external_types is a canonical-path → TS-rendering map:

let mut external_types = std::collections::BTreeMap::new();
external_types.insert("chrono::DateTime".to_string(), "string".to_string());
external_types.insert("uuid::Uuid".to_string(), "string".to_string());

Ontogen ships sensible defaults for common crates; user overrides merge on top. When the walker encounters an unrecognized external type it produces an UnresolvedReference error — the fix is to add it to the table or annotate it with #[ontogen::ts_opaque].

By default ontogen-ts only scans CARGO_MANIFEST_DIR/src. If your schema types live in workspace-sibling crates and are brought into the consuming crate via pub use, declare the sibling source roots in ClientsConfig:

let clients_config = ClientsConfig {
// ... other fields ...
pool_extra_roots: vec![
"../crates/my-schema/src".into(),
"../crates/my-config/src".into(),
],
extra_surfaces: vec![],
};

Paths resolve relative to CARGO_MANIFEST_DIR and point at the sibling’s src/.

Each root’s types are keyed under that crate’s name — read from its Cargo.toml [package] name and normalized the way Cargo does (- → _) — while the consuming crate’s own types are keyed under crate. A sibling and the consumer can therefore both define lint::Severity, and a reference resolves the way rustc would:

  • my_schema::lint::Severity names the sibling’s type outright.
  • A bare Severity means the consuming crate’s, since a bare ident can’t reach a foreign crate’s type without a use.
  • crate:: inside the sibling’s own source means that crate.

Two same-named types in one root, referenced with nothing to disambiguate, remain a hard NameCollision.

gen_seaorm emits a Relation enum per entity by convention. If your own code also defines a Relation, the long-tail resolver sees ambiguous matches and the build aborts before any client generator runs.

ClientsConfig::pool_exclude_paths drops pool entries whose module path lies under a given path, rooted at CARGO_MANIFEST_DIR the same way pool_extra_roots is. Point it at whatever directory you gave gen_seaorm:

pool_exclude_paths: vec!["src/persistence/db/entities/generated".into()],

Pipeline users get this filled in automatically from the seaorm() stage. Only direct gen_clients callers need to set it.

Two proc-macro attrs let you steer the walker without touching its decision tree.

Mark a type as terminal. The walker stops recursing into the annotated type’s fields and emits the supplied target string verbatim at every reference site.

use ontogen::ts_opaque;
#[ts_opaque(target = "Date")]
pub struct EpochSeconds(pub i64);

Every TS field of type EpochSeconds renders as Date. The attr is a no-op at Rust compile time; ontogen-ts reads it via syn during the scan.

Useful for tuple structs (which the phase-1 subset doesn’t otherwise support), newtypes wrapping primitives, and types whose TS rendering should match a JS library you import from outside the generated bundle.

Override the TS name emitted for an annotated type. The JSON wire shape is unaffected (serde never sees this attr); only ontogen-ts’s TS output uses the override.

use ontogen::ts_name;
#[ts_name = "FooStats"]
pub struct FooStatistics {
pub count: u64,
}

Useful for breaking name collisions when two reachable types render to the same TS name (a NameCollision error otherwise), or for shortening verbose Rust idents in the TS surface.

ontogen-ts is hard-error only. There is no fallback Record<string, unknown> placeholder, no warning-and-continue, no silent untyping. Either every type emits cleanly or the build fails.

Errors aggregate: a single ontogen_ts::emit call collects every EmitError it encounters and returns the full Vec so one build surfaces every problem, not just the first.

warning: ontogen-ts: long-tail type `CustomQueryResult` not found in `/.../src`
warning: ontogen-ts: unsupported shape at `crate::api::TupleStruct`: tuple struct (use #[ontogen::ts_opaque] to override)
error: failed to run custom build command for `my-app`
Pipeline build: server codegen error: ontogen-ts emit failed with 2 error(s)

The four error variants:

  • UnsupportedShape — the type’s Rust shape isn’t in the phase-1 subset. Fix: annotate with #[ontogen::ts_opaque], refactor into a supported shape (e.g., named-field struct + From impl), or file a phase-2 follow-up.
  • UnsupportedSerdeAttr — a serde attribute isn’t supported. Fix: drop the attribute, or use a workaround (split-rename → two fields + From impl). See Not supported for the current list.
  • UnresolvedReference — a referenced ident couldn’t be resolved against the pool or the external-types table. Fix: add the type’s crate to pool_extra_roots, add the canonical path to EmitConfig::external_types, or annotate the referencing type with #[ts_opaque].
  • NameCollision — two reachable types render to the same TS name. Fix: annotate one with #[ts_name = "..."].

Most callers never touch this, but ontogen-ts exposes two pool-free entry points for rendering one Rust type to its TS equivalent:

pub fn render_type(ty: &syn::Type, config: &EmitConfig) -> Result<String, EmitError>;
pub fn render_type_str(rust_ty: &str, config: &EmitConfig) -> Result<String, EmitError>;

Both run the same classifier that renders a field inside a declaration — same containers, primitives, smart-pointer peeling, and external-types table — but need no type pool, because user-defined types render as their terminal ident exactly as they do in a declaration.

This is what Ontogen itself uses to type API signatures in the generated clients, which is why a parameter’s type in transport.ts always matches the same type inside types.ts. render_type_str exists for callers whose type arrived as a rendered token stream rather than a syn::Type.

Earlier versions of Ontogen used a separate specta-based binary (src/bin/__ontogen_ts_export.rs) to emit long-tail TS types. That side-car has been removed; the ontogen-ts build-time walker replaces it. Consumer-side cleanup:

  • Delete src/bin/__ontogen_ts_export.rs (and any .gitignore / .taurignore entry for it).
  • Drop specta-typescript from your Cargo.toml deps (no longer used).
  • Drop default-run from [package] if you added it for the side-car (it was a workaround for the side-car bin making cargo run ambiguous).
  • Drop any CI env-gate (MY_APP_SKIP_SERVER_CODEGEN-style) you added to dodge the side-car’s CI disk pressure — the AST walker doesn’t have that footprint.
  • Keep specta = "...". The generated DTOs still derive specta::Type; the derive is independent of how Ontogen emits TS.

The bindings_path you configured on ClientGenerator::HttpTs / HttpTauriIpcSplit keeps the same meaning. The file is still Ontogen-owned; only the emission mechanism behind it changed.