Skip to content

Commit 9fa62f2

Browse files
committed
Iterable / iterator support and generic type parameter propagation
Adds first-class handling for the TS iterability protocol and bare generic type parameters in method signatures, so APIs like `SyncKvStorage.put<T>(key: string, value: T)` and `SyncKvStorage.list<T>(): Iterable<[string, T]>` round-trip into typed wasm-bindgen externs without erasing the generics. ## Iterators `Iterator<T>` and `IterableIterator<T>` map directly to `js_sys::Iterator<T>`; `AsyncIterator<T>` and `AsyncIterableIterator<T>` map to `js_sys::AsyncIterator<T>`. These are added as new IR variants (`TypeRef::Iterator` and `TypeRef::AsyncIterator`) and emit through the existing generic container machinery alongside `Promise<T>`. ## Iterable wrapper synthesis `Iterable<T>` describes the protocol — an object exposing `[Symbol.iterator](): Iterator<T>` — distinct from `Iterator<T>` itself. To preserve that at the binding layer, top-level `Iterable<T>` returns now synthesize a wrapper extern type with a single `[Symbol.iterator]()` method: ```ts interface SyncKvStorage { list<T>(): Iterable<[string, T]>; } ``` becomes: ```rust pub type SyncKvStorageList<T: ::wasm_bindgen::JsGeneric>; #[wasm_bindgen(method, js_name = "Symbol.iterator")] pub fn iterator<T: ::wasm_bindgen::JsGeneric>( this: &SyncKvStorageList<T>, ) -> Iterator<ArrayTuple<(JsString, T)>>; #[wasm_bindgen(method)] pub fn list<T: ::wasm_bindgen::JsGeneric>(this: &SyncKvStorage) -> SyncKvStorageList<T>; ``` The wrapper's name follows the existing `<Parent><Member>` convention with dedup, mirroring anonymous-interface parameter synthesis. `AsyncIterable<T>` synthesizes the analogous wrapper keyed on `Symbol.asyncIterator`. Nested occurrences inside unions or arrays are not synthesized — they erase to `JsValue`, matching the existing parameter-synthesis limitation. ## Generic type parameter propagation Bare TS generics on methods now survive codegen as `<T: ::wasm_bindgen::JsGeneric>` declarations rather than being erased to `JsValue`. Implementation: * New `TypeRef::TypeParam(String)` IR variant for in-scope generic type parameter references. * `convert_ts_type_scoped` returns `TypeParam(name)` instead of `Any` for names in the method's type-parameter scope. * `convert_formal_params_with_synthesis` now threads scope through to parameter type conversion (previously called `convert_ts_type`, which ignored scope). * Method codegen collects every `TypeParam` reachable from the signature and emits a generic decl bounded by `::wasm_bindgen::JsGeneric`. Synthesized iterable wrappers propagate any `TypeParam` from their item type onto the wrapper type itself, so `SyncKvStorageList` is `SyncKvStorageList<T>`. * `pub type X<T, …>` declarations carry their generics so type aliases that mention generic parameters compile. ## External non-generic type protection `GenericInstantiation` codegen now consults the codegen context's recorded type-parameter arity per local type. References like `ReadableStream<Uint8Array>` whose target accepts zero generics (non-generic web-sys / external types) drop the args and emit the bare base type, preventing `E0107`. ## Symbol-keyed methods Symbol JS names like `Symbol.iterator` previously snake_cased to `symboliterator`. `base_rust_name` now strips the `Symbol.` prefix when computing the Rust name, so the synthesized iterator methods read as `iterator` / `async_iterator` while `js_name` keeps the full `Symbol.iterator` for wasm-bindgen. ## Header comment Generated files now begin with `// Generated by ts-gen. Do not edit.` (prepended after rustfmt — `quote!` doesn't preserve line comments through token streams). ## Tests * New `tests/fixtures/iterables.d.ts` exercises every iterability variant plus generic propagation through `put<T>` / `get<T>`. * New `tests/snapshots/iterables.rs` blesses the expected output. * Four unit tests in `parse::members::tests` cover happy path (sync), async, non-iterable rejection, and dedup. * Existing snapshots updated: workers-types now produces typed `Iterator<...>`, `AsyncIterator<...>`, and per-method generics on `put` / `get` style methods that previously aliased `T` to `JsValue`. ## CONVENTIONS Two new sections (slotted next to `Promise<T>` since all three share the generic-container shape): * `Iterator<T>` / `IterableIterator<T>` map to `js_sys::Iterator<T>` * `Iterable<T>` returns synthesize a wrapper with `[Symbol.iterator]()` `AGENTS.md` updated to clarify that CONVENTIONS sections are not numbered and are ordered from simplest to most obscure.
1 parent 1f3ca2d commit 9fa62f2

20 files changed

Lines changed: 2200 additions & 893 deletions

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ complex (subtyping LUB across unions).
5858

5959
**When to update `CONVENTIONS.md`:**
6060

61-
* Adding a new TS construct → add a numbered section.
61+
* Adding a new TS construct → add a section. Sections are not
62+
numbered; order them from simplest (primitives) to most obscure
63+
(subtyping LUB across unions). Slot the new heading where it
64+
naturally falls in that progression — usually next to a related
65+
convention of similar complexity.
6266
* Changing an existing translation rule → update its section.
6367
* Bug fix that changes user-visible output → update if the fix changes
6468
the documented behaviour, otherwise just add a snapshot test.

CONVENTIONS.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,76 @@ pub async fn fetch(url: &str) -> Result<Response, JsValue>;
689689
* `wasm-bindgen` rewraps the `T` as `Promise<T>` on the JS side.
690690
* Constructors and setters never become async.
691691

692+
## `Iterator<T>` / `IterableIterator<T>` map to `js_sys::Iterator<T>`
693+
694+
```ts
695+
interface FormData {
696+
entries(): IterableIterator<[string, FormDataEntryValue]>;
697+
}
698+
```
699+
700+
emits:
701+
702+
```rust
703+
#[wasm_bindgen(method)]
704+
pub fn entries(this: &FormData) -> Iterator<ArrayTuple<(JsString, JsValue)>>;
705+
```
706+
707+
`Iterator<T>` and `IterableIterator<T>` describe runtime objects that
708+
*are* iterators (have `.next()`), so they map directly to
709+
`js_sys::Iterator<T>` with the inner type erased the same way
710+
`Promise<T>` is — generic `T` parameters become `JsValue` unless they
711+
resolve to a concrete named type at generation time.
712+
713+
`AsyncIterator<T>` and `AsyncIterableIterator<T>` map to
714+
`js_sys::AsyncIterator<T>` analogously; wasm-bindgen models the two
715+
iterator families separately.
716+
717+
## `Iterable<T>` returns synthesize a wrapper with `[Symbol.iterator]()`
718+
719+
```ts
720+
interface SyncKvStorage {
721+
list<T = unknown>(): Iterable<[string, T]>;
722+
}
723+
```
724+
725+
emits:
726+
727+
```rust
728+
#[wasm_bindgen(extends = Object)]
729+
pub type SyncKvStorageList;
730+
#[wasm_bindgen(method, js_name = "Symbol.iterator")]
731+
pub fn iterator(this: &SyncKvStorageList) -> Iterator<ArrayTuple<(JsString, JsValue)>>;
732+
733+
// And on the original interface:
734+
#[wasm_bindgen(method)]
735+
pub fn list(this: &SyncKvStorage) -> SyncKvStorageList;
736+
```
737+
738+
`Iterable<T>` describes the *protocol* — an object that exposes
739+
`[Symbol.iterator](): Iterator<T>` — distinct from `Iterator<T>` (the
740+
iterator object itself). To preserve that distinction at the binding
741+
layer, ts-gen synthesizes a wrapper interface per top-level
742+
`Iterable<T>` return:
743+
744+
* The wrapper's name is `<Parent><Method>` PascalCased, deduped
745+
against existing names (same convention as anonymous-interface
746+
parameter synthesis).
747+
* The wrapper has a single method `iterator()` keyed on `Symbol.iterator`
748+
via `js_name = "Symbol.iterator"`, returning the inner `Iterator<T>`.
749+
* The original method's return type is rewritten to the wrapper's name.
750+
751+
`AsyncIterable<T>` synthesizes the analogous wrapper using
752+
`Symbol.asyncIterator` and `AsyncIterator<T>` for the inner iterator.
753+
754+
Nested occurrences (inside unions, arrays, etc.) are not synthesized —
755+
they erase to `JsValue` at codegen, matching the existing
756+
parameter-synthesis limitation. Hoist the type manually if a wrapper is
757+
needed in those positions.
758+
759+
Symbol-keyed methods drop the `Symbol.` prefix when computing the Rust
760+
name: `Symbol.iterator` becomes `iterator`, not `symboliterator`.
761+
692762
## `@throws` JSDoc → typed error
693763

694764
```ts

src/codegen/classes.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ struct ClassConfig<'a> {
5555
is_abstract: bool,
5656
/// Members to generate.
5757
members: Vec<Member>,
58+
/// Type parameters declared on the type itself (rendered as
59+
/// `<T: JsGeneric, …>` on the `pub type` decl and propagated to
60+
/// every `this: &Type<T, …>` reference inside the extern block).
61+
type_params: Vec<crate::ir::TypeParam>,
5862
/// Codegen context for type resolution.
5963
cgctx: Option<&'a CodegenContext<'a>>,
6064
/// Scope for type reference resolution.
@@ -85,6 +89,7 @@ impl<'a> ClassConfig<'a> {
8589
js_namespace: None,
8690
is_abstract: decl.is_abstract,
8791
members: decl.members.clone(),
92+
type_params: decl.type_params.clone(),
8893
cgctx,
8994
scope,
9095
}
@@ -117,11 +122,41 @@ impl<'a> ClassConfig<'a> {
117122
js_namespace: None,
118123
is_abstract: false,
119124
members: decl.members.clone(),
125+
type_params: decl.type_params.clone(),
120126
cgctx,
121127
scope,
122128
}
123129
}
124130

131+
/// Tokens for the type-level generic declaration (`<T: JsGeneric, …>`)
132+
/// or empty when the type has no parameters.
133+
fn type_generics_decl(&self) -> TokenStream {
134+
if self.type_params.is_empty() {
135+
return quote! {};
136+
}
137+
let idents = self
138+
.type_params
139+
.iter()
140+
.map(|tp| super::typemap::make_ident(&tp.name))
141+
.collect::<Vec<_>>();
142+
quote! { <#(#idents: ::wasm_bindgen::JsGeneric),*> }
143+
}
144+
145+
/// Tokens for the type's generic-argument list (`<T, …>`) used in
146+
/// `this: &Type<T, …>` references inside the extern block. Empty
147+
/// when there are no parameters.
148+
fn type_generics_args(&self) -> TokenStream {
149+
if self.type_params.is_empty() {
150+
return quote! {};
151+
}
152+
let idents = self
153+
.type_params
154+
.iter()
155+
.map(|tp| super::typemap::make_ident(&tp.name))
156+
.collect::<Vec<_>>();
157+
quote! { <#(#idents),*> }
158+
}
159+
125160
/// Rust name to use everywhere the class identifier appears in generated
126161
/// code (`pub type X`, `this: &X`, `static_method_of = X`, …).
127162
///
@@ -976,10 +1011,12 @@ fn generate_type_decl(config: &ClassConfig) -> TokenStream {
9761011
quote! { #[wasm_bindgen(#(#wb_parts),*)] }
9771012
};
9781013

1014+
let generics_decl = config.type_generics_decl();
1015+
9791016
quote! {
9801017
#wb_attr
9811018
#[derive(Debug, Clone, PartialEq, Eq)]
982-
pub type #rust_ident;
1019+
pub type #rust_ident #generics_decl;
9831020
}
9841021
}
9851022

@@ -1069,13 +1106,47 @@ fn generate_expanded_method(config: &ClassConfig, sig: &FunctionSignature) -> To
10691106
quote! {}
10701107
};
10711108

1109+
// wasm-bindgen requires every type parameter mentioned in a method
1110+
// signature to be redeclared on the method, even when the same name
1111+
// is already on the parent type — see `js_sys::Array::for_each<T:
1112+
// JsGeneric>` for the canonical pattern.
1113+
let method_generics = generic_params_for_method(config, sig);
1114+
let this_generics = config.type_generics_args();
1115+
10721116
quote! {
10731117
#doc
10741118
#[wasm_bindgen(#(#wb_parts),*)]
1075-
pub #async_kw fn #rust_ident(this: &#this_type, #params) #ret;
1119+
pub #async_kw fn #rust_ident #method_generics (this: &#this_type #this_generics, #params) #ret;
10761120
}
10771121
}
10781122

1123+
/// Generic declaration for a method, covering both type-level parameters
1124+
/// referenced in `this: &Foo<T, …>` and any method-only parameters
1125+
/// mentioned in arguments or the return type. wasm-bindgen requires the
1126+
/// redeclaration even for type-level params.
1127+
fn generic_params_for_method(config: &ClassConfig, sig: &FunctionSignature) -> TokenStream {
1128+
// Type-level params come first, in declaration order, so that
1129+
// `<T: JsGeneric, U: JsGeneric>` aligns with the type's parameter
1130+
// list when `this: &Foo<T, U>` is referenced.
1131+
let mut names: Vec<String> = config
1132+
.type_params
1133+
.iter()
1134+
.map(|tp| tp.name.clone())
1135+
.collect();
1136+
for p in &sig.params {
1137+
super::signatures::collect_type_params(&p.type_ref, &mut names);
1138+
}
1139+
super::signatures::collect_type_params(&sig.return_type, &mut names);
1140+
if names.is_empty() {
1141+
return quote! {};
1142+
}
1143+
let idents = names
1144+
.iter()
1145+
.map(|n| super::typemap::make_ident(n))
1146+
.collect::<Vec<_>>();
1147+
quote! { <#(#idents: ::wasm_bindgen::JsGeneric),*> }
1148+
}
1149+
10791150
/// Generate a static method binding from an expanded signature.
10801151
fn generate_expanded_static_method(config: &ClassConfig, sig: &FunctionSignature) -> TokenStream {
10811152
let rust_ident = super::typemap::make_ident(&sig.rust_name);
@@ -1121,10 +1192,12 @@ fn generate_expanded_static_method(config: &ClassConfig, sig: &FunctionSignature
11211192
quote! {}
11221193
};
11231194

1195+
let generics = generic_params_for_method(config, sig);
1196+
11241197
quote! {
11251198
#doc
11261199
#[wasm_bindgen(#(#wb_parts),*)]
1127-
pub #async_kw fn #rust_ident(#params) #ret;
1200+
pub #async_kw fn #rust_ident #generics (#params) #ret;
11281201
}
11291202
}
11301203

src/codegen/mod.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ pub fn generate_with_options(
7171
syn::parse2::<syn::File>(tokens.clone()).map_err(|e| {
7272
anyhow::anyhow!("generated tokens are not valid syn:\n{e}\n\nTokens:\n{tokens}")
7373
})?;
74-
rustfmt(&tokens.to_string())
74+
let formatted = rustfmt(&tokens.to_string())?;
75+
// Prepend a header comment. Line comments don't survive `quote!` token
76+
// generation, so they have to be emitted as plain text after formatting.
77+
Ok(format!(
78+
"// Generated by ts-gen. Do not edit.\n\n{formatted}"
79+
))
7580
}
7681

7782
/// Format Rust source via `rustfmt`.
@@ -120,8 +125,6 @@ fn generate_tokens(
120125
let cgctx = CodegenContext::from_module(module, gctx);
121126

122127
let preamble = quote! {
123-
// Auto-generated by ts-gen. Do not edit.
124-
125128
#[allow(unused_imports)]
126129
use wasm_bindgen::prelude::*;
127130
#[allow(unused_imports)]
@@ -300,9 +303,25 @@ fn generate_type_alias(
300303
return quote! {};
301304
}
302305

306+
// Type-parameter declaration so aliases that mention generics survive
307+
// codegen: `type EmailExportedHandler<Env, Props> = …;` rather than the
308+
// bare `EmailExportedHandler =` that would leave `Props` undeclared.
309+
let generics = if alias.type_params.is_empty() {
310+
quote! {}
311+
} else {
312+
let idents = alias
313+
.type_params
314+
.iter()
315+
.map(|tp| typemap::make_ident(&tp.name))
316+
.collect::<Vec<_>>();
317+
// Type aliases use plain `<T, U>` without trait bounds; aliases
318+
// are erased during monomorphisation by their use sites.
319+
quote! { <#(#idents),*> }
320+
};
321+
303322
quote! {
304323
#[allow(dead_code)]
305-
pub type #name = #target;
324+
pub type #name #generics = #target;
306325
}
307326
}
308327

0 commit comments

Comments
 (0)