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 theCreate{Entity}Input/Update{Entity}InputDTOs Ontogen derives. Bounded mapping overFieldType; no AST walking. Always on. - Long-tail emitter — the
ontogen-tscrate. A build-time AST walker that scans yoursrc/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.
How the long-tail emitter works
Section titled “How the long-tail emitter works”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:
- Scan.
scan_src_dir(CARGO_MANIFEST_DIR/src)walks every.rsundersrc/and builds a pool keyed by canonicalTypePath. 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 undercrate(crate::foo::bar::Baz), each extra root under that crate’s Cargo name. That rooting is what lets a sibling and the consumer both definelint::Severitywithout colliding. - 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 likeinterval_kind: Option<IntervalKind>on a schema entity pullIntervalKindinto emission even if no API endpoint mentions it.
- Resolve. Each long-tail name is looked up in the pool: bare-ident match first, then any pool entry whose terminal segment matches.
- 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.
Supported subset (phase 1)
Section titled “Supported subset (phase 1)”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 | nullVec<T>/VecDeque<T>→T[](both serialize as a JSON array)HashMap<K, V>/BTreeMap<K, V>→Record<K, V>whereKisStringor an id-like primitive.()→null(serde serializes the unit type as JSONnull). Non-empty tuples are rejected — use a named struct.
Primitives:
bool→boolean- All integer types and
f32/f64→number(seeBigIntBehaviorfor 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.
Serde support
Section titled “Serde support”ontogen-ts reads #[serde(...)] attributes off your structs and enums so the emitted TypeScript matches the wire payload, not the Rust spelling.
Renaming
Section titled “Renaming”#[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.
Skipping
Section titled “Skipping”#[serde(skip)] on a field drops it from the TS rendering entirely.
Optional fields — #[serde(default)]
Section titled “Optional fields — #[serde(default)]”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.
Flattening — #[serde(flatten)]
Section titled “Flattening — #[serde(flatten)]”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.
Not supported
Section titled “Not supported”- Split rename (
rename(serialize = "...", deserialize = "...")) — hard error. Workaround: two fields plus aFromimpl. - The remaining shape-changing enum attrs:
tag,content,untagged— hard error.
BigInt rendering
Section titled “BigInt rendering”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) — TSnumber. Matches what most consumers expect; values above 2^53 silently truncate.BigIntBehavior::BigInt— TSbigint. The wire format is JSON; your client code must usebigintliterals.BigIntBehavior::String— TSstring. The wire payload is a JSON string; consumers parse it themselves.
String-literal quote style
Section titled “String-literal quote style”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’squotes: ['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.
External types
Section titled “External types”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].
Workspace-sibling types
Section titled “Workspace-sibling types”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(), ],};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::Severitynames the sibling’s type outright.- A bare
Severitymeans the consuming crate’s, since a bare ident can’t reach a foreign crate’s type without ause. 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.
Excluding generated types from the pool
Section titled “Excluding generated types from the pool”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.
Escape hatches
Section titled “Escape hatches”Two proc-macro attrs let you steer the walker without touching its decision tree.
#[ontogen::ts_opaque(target = "...")]
Section titled “#[ontogen::ts_opaque(target = "...")]”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.
#[ontogen::ts_name = "..."]
Section titled “#[ontogen::ts_name = "..."]”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.
Error model
Section titled “Error model”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 +Fromimpl), 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 +Fromimpl). 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 topool_extra_roots, add the canonical path toEmitConfig::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 = "..."].
Rendering a single type
Section titled “Rendering a single type”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.
Migration from the OF-014 specta side-car
Section titled “Migration from the OF-014 specta side-car”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/.taurignoreentry for it). - Drop
specta-typescriptfrom yourCargo.tomldeps (no longer used). - Drop
default-runfrom[package]if you added it for the side-car (it was a workaround for the side-car bin makingcargo runambiguous). - 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 derivespecta::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.