Skip to content

Finish auto-generated TypeScript bindings (ts feature)#4

Open
helix-nine wants to merge 6 commits into
masterfrom
feat/finish-ts-bindings
Open

Finish auto-generated TypeScript bindings (ts feature)#4
helix-nine wants to merge 6 commits into
masterfrom
feat/finish-ts-bindings

Conversation

@helix-nine

@helix-nine helix-nine commented May 29, 2026

Copy link
Copy Markdown

Finishes the WIP feature/new-ts so the ts feature compiles and emits serde-accurate TypeScript. Driven by an adversarial review of both this branch and visit-rs (the review found 38 confirmed issues).

Companion PR (required): dr-bonez/visit-rs#1 — the metadata/rename fixes this depends on. This branch points visit-rs at that fork branch with features = ["meta"]; flip it back to a published crates.io release once visit-rs#1 merges + publishes (marked with a TODO in Cargo.toml).

Was broken

feature/new-ts did not compile: src/ts.rs's SerdeTag parsed serde attributes with syn at runtime (not a dependency), and enum serde-tagging was a // TODO stub. Even ignoring that, the enum path never emitted variant names, Option had no TS impl, u64 mapped to bigint, map keys used numeric/bigint index signatures (the latter is illegal TS), and a no_ts() child emitted bare unknown that poisoned inference for the whole tree.

Changes

  • Serde enum tagging, metadata-driven. Replaced the runtime-syn SerdeTag with enum_repr, which reads the representation from visit-rs's runtime AttributeMeta. Variant::visit now emits the correct shape for all four representations and every variant kind:
    • external (default): {"V": payload}, and "V" for unit variants
    • internal #[serde(tag)]: {"tag":"V"} & payload
    • adjacent #[serde(tag, content)]: {"tag":"V"; "content": payload} (no content key for unit)
    • untagged: bare payload (null for unit)
  • impl_ts_enum! macro so enums opt in (parallel to impl_ts_struct!).
  • Option<T>T | null, plus field-metadata handling in Named::visit: serde skip/skip_serializing fields are dropped, and skip_serializing_if/default make the key optional (field?:).
  • Primitives: all integer widths → number (serde_json emits JSON numbers, never bigint). Maps: {[key: string]: V} regardless of key type (JSON object keys are always strings; also removes the illegal bigint index signature).
  • no_ts() children emit {_PARAMS:unknown;_RETURN:unknown} instead of bare unknown, so one opted-out child no longer fails the RpcHandler constraint and poison sibling inference.
  • insert_definition uses debug_assert_eq! instead of a release-mode panic! on name clashes (library code).
  • --no-default-features builds: gated the impl_ts_struct! import and Empty invocation behind cfg(feature = "ts").
  • Output assembly (was missing entirely): TSVisitor::into_module(root_name) renders a full module (helpers + export type for each DEFINE + the root alias), and handler_bindings(&handler, name) builds it straight from a handler tree. New examples/generate_ts.rs.

Tests

tests/ts_bindings.rs asserts the exact TS for: a renamed struct with Option/skip/skip_if/u64/Vec, all four enum representations (incl. unit/newtype/tuple/struct variants), maps, named DEFINE references + module assembly, and a full handler tree (including a no_ts child). Verified end-to-end with tsc --strict: the generated module type-checks, RpcParamType/RpcReturnType resolve correctly, mistyped payloads are rejected, and a no_ts child does not poison siblings. All build configs green (default, --no-default-features [--features cbor]).

Verified-but-deferred (from the review; not blockers for the feature)

  • #[serde(flatten)] (needs hoisting inner fields via intersection) and #[serde(transparent)].
  • Flat<A, B>: TS — only needed if a handler's own Params is a Flat (inherited params are represented through the parent chain in type-helpers.ts, so the common nested case works).
  • ParentHandler root-handler (a directly-callable parent) is not yet surfaced in _CHILDREN.
  • On the visit-rs side: generic/lifetime-enum impl generics and enum-variant-field skip in the derive (variant-field skip is handled here consumer-side via metadata).

Note: enums used with impl_ts_enum! currently need use visit_rs::{Visit, EnumInfo}; in scope (the VisitVariants derive uses method-call syntax). Worth making the derive import-free in a visit-rs follow-up.

dr-bonez and others added 2 commits May 29, 2026 17:59
Squash of the feature/new-ts WIP (replace ts lib, better TS, metadata)
rebased onto master. Original work by Aiden McClelland.

Co-authored-by: helix-nine <267227783+helix-nine@users.noreply.github.com>
Makes the `ts` feature compile and produce serde-accurate TypeScript.

- Drop the runtime-`syn` SerdeTag hack; resolve the serde enum
  representation (external/internal/adjacent/untagged) from visit-rs's
  runtime AttributeMeta, and emit correct TS for every variant kind:
  external `{"V":payload}` / "V" for unit, internal `{tag:"V"}&payload`,
  adjacent `{tag:"V";content:payload}`, untagged bare payload.
- Add impl_ts_enum! so enums can opt in (parallels impl_ts_struct!).
- impl TS for Option<T> (T|null); honor serde skip/skip_serializing
  (omit) and skip_serializing_if/default (optional `?:`) via field metadata.
- Map all integer widths to `number` (serde_json emits JSON numbers, not
  bigint) and map keys to `{[key:string]:V}` (JSON keys are always strings).
- no_ts() children emit a well-formed `{_PARAMS:unknown;_RETURN:unknown}`
  leaf instead of bare `unknown` (which poisoned the whole tree's inference).
- insert_definition no longer panics in release on name clashes.
- Gate the impl_ts_struct! use behind cfg(ts) so --no-default-features builds.
- Add TSVisitor::into_module + handler_bindings() to emit a complete .d.ts
  module, plus examples/generate_ts.rs.
- Depend on the fixed visit-rs branch (features=["meta"]); revert to a
  published release once dr-bonez/visit-rs#1 merges.
- Tests assert exact TS for structs, all four enum taggings, Option, maps,
  named definitions, and a handler tree; verified end-to-end with tsc --strict.
@helix-nine helix-nine force-pushed the feat/finish-ts-bindings branch from eeb2809 to 29ac427 Compare May 29, 2026 18:02
@helix-nine helix-nine changed the base branch from feature/new-ts to master May 29, 2026 18:02
@helix-nine

Copy link
Copy Markdown
Author

Rebased onto master (PR base changed from feature/new-tsmaster). The branch is now two clean commits on top of current master:

  1. feat(ts): replace ts-rs with visit-rs-based TS bindings (WIP) — the squashed feature/new-ts work, attributed to @dr-bonez.
  2. feat(ts): finish auto-generated TypeScript bindings — the finishing work.

Master's changes since the old branch point were preserved through the rebase: #[group(skip)] on Empty/Never, GenericRpcMethod over InternedString, the cli.rs additions (dyn methods / call_remote metadata / CliApp API / mutate_command), server/http.rs, and remove ts-rs default feature.

One decision to confirm: I kept ts opt-in (default = ["cbor"], matching master's remove ts-rs default feature) rather than re-adding it to default. The main reason: visit-rs is currently a git dependency (the fork branch), so having ts on by default would force every downstream consumer (e.g. start-os) to pull the git dep. Once dr-bonez/visit-rs#1 is merged + published and the dep becomes a normal crates.io dependency, flipping ts back into default is a one-liner if you'd prefer that. (The tests/test.rs and tests/ts_bindings.rs integration tests and the example are gated with required-features = ["ts"] accordingly.)

Verified after rebase: cargo build / cargo test (default, no ts) clean; cargo test --features ts → 10 passing; cargo build --no-default-features --features cbor clean; example regenerates byte-identical bindings; tsc --strict round-trip still green.

- flatten: capture the inner type and emit it as a TS intersection member
  (`{...own fields} & Inner`) once the object body is closed, matching serde's
  field-splicing on the wire.
- transparent: emit the single field's type directly, with no object wrapper.
@helix-nine

Copy link
Copy Markdown
Author

Added two more of the deferred gaps (commit 8a4ff57):

  • #[serde(flatten)] → emitted as a TS intersection: {...own fields} & Inner (captures the flattened field's inner type and intersects it once the object body closes), matching serde's field-splicing.
  • #[serde(transparent)] → emits the single field's type directly, no object wrapper.

Both covered by new assertions in tests/ts_bindings.rs (11 ts tests now); cargo test --features ts green.

Still deferred (documented for follow-up):

  • Generic/lifetime enums (visit-rs): I attempted the impl<__visit_rs__V, …> splice fix, but it's only part of the problem — the VisitVariants derive also uses a non-hygienic 'a (collides with enum lifetimes) and omits the T: 'a / Send / Sync bounds the trait signatures require, so generic enums still won't compile. It needs the enum derive to mirror the struct derive's make_impl (hygienic '__visit_rs__a + proper bounds). Reverted the partial fix rather than ship something that doesn't actually compile for generic enums.
  • Import-free VisitVariants derive (visit-rs): enums currently need use visit_rs::{Visit, EnumInfo}; in scope. DX nicety; needs the derive's method bodies to use fully-qualified calls (the struct derive already does).
  • #[serde(skip)] on enum-variant fields in the derive: for the TS output, named variant fields are already skipped consumer-side via metadata; the derive-level fix (and field_count) is a rarer concern.
  • Flat<A, B>: TS: only needed if a handler's own Params is a Flat (inherited params are represented through the parent chain), so not hit in the common case.

One open design question for the callable-parent gap (ParentHandler root handler) — see the thread.

- A ParentHandler with a root_handler is itself callable at the empty method
  path: surface the root handler's return as the parent node's _RETURN (its
  params already equal the parent's _PARAMS, per root_handler's bound), and
  teach type-helpers.ts that an empty method resolves to the node's own
  _PARAMS/_RETURN (single segments recurse into the child with an empty method,
  unifying leaf and directly-callable parent children).
- impl TS for Flat<A,B> as a TS object intersection (A & B), matching serde's
  merged-object wire format for inherited params.
- Drop now-unneeded visit-rs trait imports (the VisitVariants derive is
  import-free as of visit-rs branch); bump the visit-rs git pin.
@helix-nine

Copy link
Copy Markdown
Author

Closed two more gaps (commit 4addade):

  • Callable parents (your choice (b)). A ParentHandler with a root_handler is now callable at the empty method path: the parent node carries the root handler's _RETURN (its _PARAMS already equal the parent's, enforced by root_handler's Params = Params bound), and type-helpers.ts now resolves an empty method to the node's own _PARAMS/_RETURN. Single segments recurse into the child with an empty method, which unifies leaf children and directly-callable parent children. examples/generate_ts.rs now has a root_handler, and the tsc --strict round-trip confirms RpcReturnType<Api, "jobs"> resolves to the root handler's return while jobs.run still resolves as a leaf.
  • Flat<A, B>: TS as a TS object intersection (A & B), matching serde's merged-object wire format for inherited params.

Also dropped the use visit_rs::{Visit, EnumInfo} imports now that the VisitVariants derive is import-free (visit-rs PR #1), and bumped the visit-rs git pin. 14 ts tests pass; default / --features ts / --no-default-features --features cbor all build warning-free.

Still deferred (one item): generic/lifetime enums in the derive. Digging in, this isn't a quick gap — it's a derive-wide rework: lifetime params fail for structs too, and the enum methods need hygienic '__visit_rs__a (done in a spike) plus per-generic-parameter bounds (T: '__a, and Send/Sync for the async stream methods) and RPITIT lifetime alignment with the trait. I reverted the partial fix rather than ship something that compiles for plain enums but still not for generic/lifetime ones. Happy to take it on as its own focused PR if you want generic enums supported — it'd touch both the struct (make_impl) and enum derives. (enum variant-field #[serde(skip)] in the derive is also still deferred — low value since named variant fields are already dropped consumer-side via metadata.)

visit-rs now supports generic enums (for 'static type params), so a generic
RPC enum can be rendered to TS via impl_ts_enum! on a concrete instantiation.
@helix-nine

Copy link
Copy Markdown
Author

Bumped the visit-rs pin to pick up generic enum support and added a generic-enum TS test (commit 82e8213): a generic RPC enum Resp<T> renders to TS via impl_ts_enum!(Resp<u32>) -> |{"Ok":number}|{"Err":string}. 14 ts tests pass; default / --features ts / --no-default-features --features cbor all build warning-free; tsc round-trip still green.

With this, all the previously-deferred gaps are closed (flatten, transparent, callable parents, Flat<A,B>, import-free derive, and generic enums). See dr-bonez/visit-rs#1 for the generic-enum derive work this depends on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants