Add #[js_trait] and #[wasm_implements]#3
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new wasm_trait proc-macro crate that generates Rust/TypeScript “duck-typed” JS interfaces as Rust traits, plus a companion macro to verify (at compile time) that #[wasm_bindgen] exports conform to those interfaces.
Changes:
- Introduces
#[js_trait]to generate TS interface +extern "C"bindings + a cleaned Rust trait + animpl Trait for JsExternType. - Introduces
#[wasm_implements]to inject a runtime tag and generate a compile-time witness + JS-name conformance checks. - Adds a new integration test crate and wires the new crate(s) into the workspace / lockfiles.
Reviewed changes
Copilot reviewed 23 out of 25 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| wasm_trait/src/lib.rs | Exposes js_trait and wasm_implements proc-macro entry points + crate-level docs. |
| wasm_trait/src/js_trait.rs | Implements #[js_trait] expansion: TS section, extern bindings, trait emission, and delegating impl. |
| wasm_trait/src/wasm_implements.rs | Implements #[wasm_implements] expansion: tag injection, witness impl, and JS-name assertions. |
| wasm_trait/src/shared.rs | Shared helper module wiring for the new macros. |
| wasm_trait/src/shared/attrs.rs | Helpers for parsing/stripping #[wasm_bindgen(...)] attributes and extracting js_name. |
| wasm_trait/src/shared/method.rs | Helpers for arg extraction, receiver detection, and async→RPITIT signature transformation. |
| wasm_trait/src/shared/naming.rs | Centralized naming for generated identifiers/constants. |
| wasm_trait/src/shared/ts_types.rs | Rust→TypeScript type mapping used by generated TS interface output. |
| wasm_trait/README.md | Crate-level README describing the macros and usage examples. |
| wasm_trait/Cargo.toml | Declares the new wasm_trait proc-macro crate and dependencies. |
| tests/wasm_trait_tests/Cargo.toml | Adds a new integration test crate to compile-check macro expansions against real wasm-bindgen types. |
| tests/wasm_trait_tests/src/lib.rs | Test module harness for wasm_trait integration tests. |
| tests/wasm_trait_tests/src/sync_trait.rs | Integration tests for sync trait method generation and bounds usage. |
| tests/wasm_trait_tests/src/async_trait.rs | Integration tests for async trait methods, typed errors, and Future-return behavior. |
| tests/wasm_trait_tests/src/implements.rs | Integration tests for #[wasm_implements] witness generation + generic use. |
| tests/wasm_trait_tests/src/static_methods.rs | Integration tests covering no-receiver (“static”) method scenarios. |
| tests/wasm_trait_tests/src/js_name_override.rs | Integration test validating js_name override behavior. |
| tests/wasm_trait_tests/src/qa_edge_cases.rs | Broad QA-style compile-checks for edge cases and regression coverage. |
| tests/wasm_trait_tests/src/realistic_app.rs | Larger “realistic app” compile-check exercising multiple traits, bounds, and mock impls. |
| Cargo.toml | Adds wasm_trait and wasm_trait_tests as workspace members; adds wasm-bindgen-futures to workspace deps. |
| Cargo.lock | Locks new dependencies for the new crate and tests. |
| wasm_refgen/Cargo.toml | Adds keywords/categories metadata. |
| from_js_ref/Cargo.toml | Adds readme/keywords/categories metadata. |
| flake.lock | Updates Nix inputs used by the repository. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 29 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 29 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 29 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 29 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 29 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| format!(" {method_name}({params_str}): {ts_return};") | ||
| }) | ||
| .collect(); | ||
|
|
||
| let ts_body = ts_methods.join("\n"); |
There was a problem hiding this comment.
gen_ts_section() emits every trait method as an instance method in the TypeScript interface. For receiver-less trait methods (fn foo(...)), Rust treats them as associated functions, while the current extern generation treats them as free functions; representing them as instance methods in TS is inconsistent and will mislead JS consumers.
Consider omitting/segregating receiver-less methods in the generated TS (e.g. a separate *Static companion type), or disallow receiver-less methods for #[js_trait] until the TS/binding model is aligned.
| FnArg::Receiver(_) => None, | ||
| FnArg::Typed(pat_type) => { | ||
| let pat = &pat_type.pat; | ||
| Some(quote!(#pat)) | ||
| } |
There was a problem hiding this comment.
arg_names() returns the full parameter pattern (e.g. mut x) and callers splice that into expression contexts when generating delegation calls. This will produce invalid Rust like foo(mut x) for signatures that use mut/ref bindings.
Consider extracting just the identifier from Pat::Ident (or rejecting mut/ref bindings during validation) so generated calls pass plain variables.
| let method_name = &mi.method.sig.ident; | ||
| let extern_name = extern_fn_ident(method_name); | ||
|
|
||
| let mut wb_metas: Vec<TokenStream> = Vec::new(); | ||
|
|
||
| if mi.has_self { | ||
| wb_metas.push(quote!(method)); | ||
| } | ||
|
|
There was a problem hiding this comment.
For trait methods without a receiver, the generated extern "C" binding has no this parameter and no method meta (i.e. it imports a free function from the JS global scope). That doesn’t line up with the “static method” use case shown in tests/wasm_trait_tests/src/static_methods.rs, and it also diverges from how the TS interface presents the method.
Consider using #[wasm_bindgen(static_method_of = ...)]/js_class for receiver-less methods, or disallow receiver-less methods in #[js_trait] until the TS + binding model is consistent.
| let method_name = &mi.method.sig.ident; | |
| let extern_name = extern_fn_ident(method_name); | |
| let mut wb_metas: Vec<TokenStream> = Vec::new(); | |
| if mi.has_self { | |
| wb_metas.push(quote!(method)); | |
| } | |
| if !mi.has_self { | |
| return syn::Error::new_spanned( | |
| &mi.method.sig, | |
| "#[js_trait] methods must have a receiver; receiver-less methods would generate free-function wasm bindings instead of JS methods", | |
| ) | |
| .to_compile_error(); | |
| } | |
| let method_name = &mi.method.sig.ident; | |
| let extern_name = extern_fn_ident(method_name); | |
| let mut wb_metas: Vec<TokenStream> = Vec::new(); | |
| wb_metas.push(quote!(method)); |
| format!(" {method_name}({params_str}): {ts_return};") | ||
| }) | ||
| .collect(); | ||
|
|
||
| let ts_body = ts_methods.join("\n"); | ||
| let ts_section_str = format!("export interface {ts_interface_name} {{\n{ts_body}\n}}"); |
There was a problem hiding this comment.
The TypeScript generator always emits methodName(params): ret; entries, even for receiver-less trait methods. Those methods are associated functions in Rust, but are presented as instance methods in TS, which is inconsistent with both Rust calling semantics and the current extern binding behavior.
Consider omitting/segregating receiver-less methods in the generated TS (e.g. a separate *Static companion type) or rejecting them for #[js_trait].
This PR cleans up a heretofore manual pattern that I've been getting a LOT of mileage out of: duck typing JS and plumbing it through Rust traits.
#[js_trait(js_type = JsTransport)]on a trait definition — generates anextern "C"block, TypeScript interface, Rust trait, andimpl Trait for ExternType#[wasm_implements(Transport)]on a#[wasm_bindgen]impl block — compile-time check that the exported struct conforms to the interface, plus a runtime tag for duck-type checkingExample Use
FAQ
async fnin traits (not RPITIT) for ergonomic mock implsResult<Uint8Array, JsValue>notResult<JsValue, JsValue>) drive both TypeScript generation andunchecked_intoconversion__JS_INTERFACE_*const + compile-time string comparison catches missing/wrong#[wasm_bindgen(js_name)]attrs