Skip to content

Generate Typescript types for Rust JSG Bindings - #8077

Open
nathanwhit wants to merge 3 commits into
mainfrom
nathanwhit/rust-rtti
Open

nathanwhit wants to merge 3 commits into
mainfrom
nathanwhit/rust-rtti

Conversation

@nathanwhit

@nathanwhit nathanwhit commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Motivation

@cloudflare/workers-types is generated from the RTTI export (workerd:rtti), which only knows about C++ bindings. Rust modules register a callback the exporter can't inspect, and its overload for them is a TODO that does nothing. As more APIs are written in Rust, they need to show up in the generated types like C++ ones do.

Design

1. Members are added to a registry

To describe a resource, the RTTI export needs the list of members that the resource's JavaScript class actually gets.
That list comes from Resource::members, which returns a Vec<Member>. That doesn't work for RTTI, for two reasons:

  • A Member has no types. It holds what template creation needs: a name and the callbacks V8 calls. There is
    nothing in it to describe in TypeScript.

  • The list is chosen at runtime. With custom_members, members is hand-written code that can choose members by
    compatibility flag:

    fn members(flags: compatibility_flags::Reader<'_>) -> Vec<Member> {
        let placement = if flags.get_jsg_property_on_prototype_template() {
            PropertyPlacement::Prototype
        } else {
            PropertyPlacement::Instance
        };
        let mut members = vec![Self::get_mode_member(placement)];
        if flags.get_cache_option_enabled() {
            members.push(Self::cached_member());
        }
        members
    }

    A separate, macro-generated description couldn't see these choices. It would have to copy them, and would drift from
    the real class when the code changes.

So RTTI has to run members itself, and members has to produce something RTTI can describe. Resource::members now
adds each member to a registry instead of returning a list, like the Registry parameter of C++ registerMembers:

// before
fn members(flags: compatibility_flags::Reader<'_>) -> Vec<Member>
// after
fn members(registry: &mut impl MemberRegistry, flags: compatibility_flags::Reader<'_>)

Template creation and the RTTI export run the same members, each with its own registry. Template creation keeps the
callbacks and never builds a type description; RTTI keeps the type descriptions and never builds a callback. So the
TypeScript declarations match what each resource registers under the export's compatibility flags, including choices like
those above.

Generated member factories (Self::foo_member()) now return impl jsg::MemberDefinition instead of jsg::Member. A definition can produce the member's callbacks or its type description, whichever the registry asks for. A jsg::Member
built by hand can still be passed to registry.add. RTTI describes a hand-built nested type through the type it nests,
and omits other hand-built members, which have no description.

2. TypeInfo describes Rust types

jsg::TypeInfo describes how a Rust type looks in JavaScript, using the existing rtti.capnp schema and without an isolate. #[jsg_resource], #[jsg_struct] and #[jsg_oneof] generate the implementation; built-in and custom conversion types implement it by hand. The types/ generator is unchanged.

#[jsg_struct(name = "LookupResult")]
struct ResultData {
    pub label: String,
    pub detail: jsg::Nullable<String>,
}

#[jsg_resource(name = "Example", ts_root)]
struct Example;

#[jsg_resource]
impl Example {
    #[jsg_method(name = "lookup")]
    fn lookup_value(&self, label: String, detail: jsg::Nullable<String>) -> jsg::Result<ResultData> { /* ... */ }

    #[jsg_method]
    fn hint(&self, hint: Option<jsg::Nullable<String>>) -> Option<jsg::Nullable<String>> { /* ... */ }

    #[jsg_property(prototype, name = "title", readonly)]
    fn get_label(&self) -> String { /* ... */ }
}
interface LookupResult {
  label: string;
  detail: string | null;
}
declare class Example {
  lookup(label: string, detail: string | null): LookupResult;
  hint(hint?: string | null): string | null | undefined;
  get title(): string;
}
Rust TypeScript
Option<T> T | undefined (optional)
jsg::Nullable<T> T | null (input also reads undefined as null; the parameter is required)
Option<jsg::Nullable<T>>, jsg::Lenient<T> T | null | undefined (optional)
Vec<T>, &[T] T[] (numbers included)
jsg::Dict<V> Record<string, V>
v8::Local<v8::Uint8Array> and other typed arrays Uint8Array, etc.
Result<T, E> T
SystemTime Date (arguments also accept number)
v8::Local<v8::Date> Date
i64, u64, v8::Local<BigInt> number | bigint (as C++ describes int64_t)
v8::Local<Promise> Promise<any> (arguments also accept plain values, as in C++)
  • Names: name = "..." sets the TypeScript name of a method, property, struct or resource. References between types use the full Rust type name, so renaming a type doesn't break them; two types with the same full name are rejected. Each type is recorded before its members are described, so shared and self-referencing types appear once.
  • Signatures: a leading this parameter and a &mut Lock parameter are left out. Parameter names are converted to camelCase, lose the leading _ of unused parameters, and escape TypeScript reserved words. Two parameters that end up with the same name are a compile error.
  • Members: described in registration order, as in C++. A nested type (nested_types(...), or a hand-built Member::NestedType) is described with its whole structure, so it is declared as a class even if nothing else refers to it. Inspect properties, wildcard properties and other hand-built members have no description, also as in C++.
  • Roots: ts_root matches JSG_TS_ROOT. A type only appears in the ambient declarations if it can be reached from a root.

3. Module types are included in the export

To describe a module, the exporter needs its exports type without running the module. So module callbacks now return the exports resource, and registration converts it to JS:

// before
jsg::modules::add_builtin(registry, "node-internal:dns", |lock| DnsUtil::new().to_js(lock), ModuleType::Internal);
// after
jsg::modules::add_builtin(registry, "node-internal:dns", |_lock| Ok(DnsUtil::new()), ModuleType::Internal);

Callbacks still capture nothing, and an error a callback returns is still thrown to the importer.

Registration passes the C++ registry a second function pointer next to the callback, which describes the exports type. RustModuleRegistry uses an if constexpr to check whether the wrapped registry has addRustBuiltinModule; only the exporter does.

  • The exporter calls the describe pointer and never runs the module. It describes every Rust module into one shared context under the export's compatibility flags, then adds the result to the C++ types and modules.
  • Runtime registries ignore the describe pointer.

Commits

  1. Register Rust resource members through a MemberRegistry. Runtime only; contains the API change.
  2. Describe Rust JSG bindings through TypeInfo.
  3. Include Rust module types in the RTTI export.

Not in this PR

  • JSG_TS_OVERRIDE / JSG_TS_DEFINE equivalents: a follow-up PR.

Testing

  • rtti-test.rs: shared and recursive types are recorded once, modules share one set of types, custom members registration is followed under different compatibility flags, a hand-built nested type is described with its whole structure, and two types with one name are rejected.
  • types/test/generator/rust.spec.ts: a Rust fixture covering the mappings above is run through the generator, and the resulting declarations are type-checked with TypeScript, including that a Nullable argument is required and an Option<Nullable> one is optional, and that a renamed nested type reachable only through nesting is declared, along with a struct only it refers to. A flag-dependent resource is exported with two sets of flags. The fixture is built as test data rather than as a build tool, so V8 isn't built a second time for the host platform.
  • api-rtti-test: the real export includes node-internal:dns, its exports type DnsUtil, and the CaaRecord struct it returns.
  • Compile test: two parameters with the same TypeScript name are rejected.
  • Generated types: @cloudflare/workers-types is unchanged, since no Rust type is a TypeScript root yet.

@nathanwhit
nathanwhit requested review from a team as code owners October 28, 2026 21:53
@nathanwhit
nathanwhit requested a review from edmundhung October 28, 2026 21:53
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

✅ Approved — No issues found in the changes since the last review. One earlier informational note about the root AGENTS.md wording is still open and has not been repeated.

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

This change only generates types. It adds no paths for untrusted input and does not change authentication or authorization.


📖 AGENTS.md: ✅ LGTM

No new issues. One note from earlier reviews is still open and is not repeated here: the root AGENTS.md still says TypeScript types are extracted only from C++ RTTI.


📋 Code Quality: ✅ LGTM

No correctness, error-handling or test-coverage problems in rtti.rs, jsg/lib.rs, jsg-macros/lib.rs, the RTTI fixture or rust.spec.ts.

  • Each structure is recorded before its members are described, so recursive types appear once.
  • Two types with the same type name are rejected.
  • Recursion over nested types is bounded.

⚡ Performance: ✅ LGTM

  • The describe path runs once per type export, and registries used at runtime ignore it.
  • Nested types are written again each time they are nested, but the nesting check bounds this.
  • The Rust RTTI message is copied once into an aligned buffer, a one-off cost.

📝 Documentation: ✅ LGTM

The README and src/rust/AGENTS.md updates match the members(registry, flags) API, the TypeInfo type mappings and the new module callback shape.


Reviewed commit: a884f684

@ask-bonk

ask-bonk Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Since last review: 2 resolved, 0 still open, 0 new.
LGTM!

Not re-run: kj-style (no author changes in their files since the last review)


Reviewed commit: b8b3d091 · github run

Comment thread src/rust/jsg-macros/resource.rs Outdated
Comment thread src/rust/jsg-macros/lib.rs
@nathanwhit
nathanwhit force-pushed the nathanwhit/rust-rtti branch 2 times, most recently from b8b3d09 to 3b8788e Compare October 28, 2026 23:00
@codecov-commenter

codecov-commenter commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.61916% with 144 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.17%. Comparing base (f03ec01) to head (a884f68).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/rust/jsg/rtti.rs 60.83% 103 Missing ⚠️
src/workerd/api/api-rtti-test.c++ 75.86% 0 Missing and 14 partials ⚠️
src/rust/jsg/resource.rs 58.06% 13 Missing ⚠️
src/rust/jsg/feature_flags.rs 0.00% 12 Missing ⚠️
src/workerd/api/rtti.c++ 92.30% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8077      +/-   ##
==========================================
- Coverage   69.25%   68.17%   -1.08%     
==========================================
  Files         698      713      +15     
  Lines      182499   186136    +3637     
  Branches    29224    29284      +60     
==========================================
+ Hits       126383   126907     +524     
- Misses      39043    42133    +3090     
- Partials    17073    17096      +23     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Resource::members adds each member to a registry, members(registry,
flags), instead of returning a Vec<Member>, like the Registry parameter
of C++ registerMembers. Generated member factories return
`impl jsg::MemberDefinition`, which template creation builds into a
Member; a hand-built Member is also a MemberDefinition.

This prepares for RTTI: an RTTI registry can run the same registration
code and collect type descriptions instead of callbacks, so TypeScript
declarations follow the members a resource registers under a given set
of compatibility flags.
Add jsg::TypeInfo, which describes a binding type's JavaScript
representation in the shared rtti.capnp schema without an isolate.
#[jsg_resource], #[jsg_struct] and #[jsg_oneof] generate it; built-in
conversion types implement it by hand, as custom conversion types must.

A resource is described with the members it registers. Member factories
return a definition that can both build the member and describe it, and
RTTI runs Resource::members with its own registry under the
compatibility flags of the jsg::rtti::Context, as C++ runs
registerMembers with the RTTI builder's flags. Template creation never
runs the descriptions, and RTTI never builds callbacks. As in C++, a
nested type is described with its whole structure, through which the
generator reaches it, and inspect properties, wildcard properties and
other hand-built members have no description.

The Context interns named types by TypeId before describing their
members, so shared and recursive types are emitted once. Their type
names identify them to the generator, so two types with one type name
are rejected.
Declaration names follow `name = "..."` separately, and ts_root marks a
root of the ambient declarations, like JSG_TS_ROOT. Parameter names are
camelCased, lose unused-parameter underscores and escape TypeScript
reserved words; parameters whose names collide are rejected.

Cover the mappings with an end-to-end test that runs a Rust fixture's
RTTI through the types/ generator and type-checks the result. The
fixture runs as test data in the target configuration, to avoid building
V8 again as an exec-configuration tool. Teach the formatter to cover
Rust sources under types/.
The RTTI export could not describe Rust builtin modules: they register
an opaque callback, and its EncoderModuleRegistryImpl overload for
module callbacks does nothing.

Have module callbacks return their exports resource, jsg::Result<Rc<T>>,
which registration converts, so the exports type is known statically.
Registration passes the C++ registry a second function pointer next to
the callback, which describes T in a jsg::rtti::Context. The
RustModuleRegistry adapter hands it to registries that define
addRustBuiltinModule, which only the workerd:rtti encoder does, and
runtime registries ignore it. The encoder thus collects every module's
types in one Context, under the export's compatibility flags, without
running module code, and merges the encoded groups and modules with the
C++ ones. Callbacks still capture nothing, and an error one returns is
still thrown to the importer.

api-rtti-test checks that the export discovers node-internal:dns, its
exports type and the structs that type returns.
@nathanwhit
nathanwhit force-pushed the nathanwhit/rust-rtti branch from 3b8788e to a884f68 Compare October 29, 2026 03:39
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