Skip to content

Add #[js_trait] and #[wasm_implements]#3

Merged
expede merged 21 commits into
mainfrom
wasm_trait
Apr 6, 2026
Merged

Add #[js_trait] and #[wasm_implements]#3
expede merged 21 commits into
mainfrom
wasm_trait

Conversation

@expede

@expede expede commented Apr 6, 2026

Copy link
Copy Markdown
Member

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.

  1. #[js_trait(js_type = JsTransport)] on a trait definition — generates an extern "C" block, TypeScript interface, Rust trait, and impl Trait for ExternType
  2. #[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 checking

Example Use

use wasm_trait::{js_trait, wasm_implements};

// Define a JS interface as a Rust trait:
#[js_trait(js_type = JsStorage)]
pub trait Storage {
    #[wasm_bindgen(js_name = "save")]
    async fn js_save(&self, key: String, value: JsValue) -> Result<(), JsValue>;

    #[wasm_bindgen(js_name = "load")]
    async fn js_load(&self, key: String) -> Result<JsValue, JsValue>;

    #[wasm_bindgen(js_name = "name")]
    fn js_name(&self) -> String;
}

// Any JS object matching the interface can now be used as `&JsStorage`
// or through the trait: `fn use_it(s: &impl Storage) { ... }`

// Verify a Rust export conforms at compile time:
#[wasm_implements(Storage)]
#[wasm_bindgen(js_class = "MemoryStorage")]
impl WasmMemoryStorage {
    #[wasm_bindgen(js_name = "save")]
    pub async fn js_save(&self, key: String, value: JsValue) -> Result<(), JsValue> { ... }

    #[wasm_bindgen(js_name = "load")]
    pub async fn js_load(&self, key: String) -> Result<JsValue, JsValue> { ... }

    #[wasm_bindgen(js_name = "name")]
    pub fn js_name(&self) -> String { "memory".into() }
}
// Missing or mistyped methods → compile error
// Wrong #[wasm_bindgen(js_name)] → compile error

FAQ

  • Async methods use async fn in traits (not RPITIT) for ergonomic mock impls
  • Precise return types (Result<Uint8Array, JsValue> not Result<JsValue, JsValue>) drive both TypeScript generation and unchecked_into conversion
  • JS method name checking via hidden __JS_INTERFACE_* const + compile-time string comparison catches missing/wrong #[wasm_bindgen(js_name)] attrs
  • No runtime cost — all checking is compile-time, tags are opt-in duck-type markers

@expede expede marked this pull request as ready for review April 6, 2026 05:44
Copilot AI review requested due to automatic review settings April 6, 2026 05:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + an impl 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.

Comment thread wasm_bindgen_trait/src/wasm_implements.rs
Comment thread wasm_bindgen_trait/src/js_trait.rs
Comment thread wasm_trait/src/shared/method.rs Outdated
Comment thread wasm_bindgen_trait/src/js_trait.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread wasm_bindgen_trait/src/wasm_implements.rs
Comment thread wasm_trait/src/wasm_implements.rs Outdated
Comment thread wasm_trait/src/lib.rs Outdated
Comment thread wasm_trait/src/shared/ts_types.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread wasm_bindgen_trait/src/shared/method.rs
Comment thread wasm_bindgen_trait/src/js_trait.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread wasm_bindgen_trait/src/js_trait.rs
Comment thread wasm_bindgen_trait/src/js_trait.rs
Comment thread wasm_bindgen_trait/src/wasm_implements.rs
Comment thread wasm_bindgen_trait/src/js_trait.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread wasm_bindgen_trait/src/js_trait.rs
Comment thread tests/wasm_trait_tests/src/async_trait.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +405 to +409
format!(" {method_name}({params_str}): {ts_return};")
})
.collect();

let ts_body = ts_methods.join("\n");

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +20
FnArg::Receiver(_) => None,
FnArg::Typed(pat_type) => {
let pat = &pat_type.pat;
Some(quote!(#pat))
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +436 to +444
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));
}

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Comment on lines +405 to +410
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}}");

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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].

Copilot uses AI. Check for mistakes.
@expede expede merged commit cdfd23d into main Apr 6, 2026
9 checks passed
@expede expede deleted the wasm_trait branch April 6, 2026 16:21
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