Conversation
krodak
force-pushed
the
kr/generics-export-side
branch
3 times, most recently
from
September 22, 2026 09:00
6463336 to
317896e
Compare
krodak
force-pushed
the
kr/generics-export-side
branch
from
September 22, 2026 14:22
317896e to
5c72ff8
Compare
krodak
changed the base branch from
main
to
kr/protocol-refinement-upstream
September 22, 2026 14:22
krodak
force-pushed
the
kr/generics-export-side
branch
from
September 28, 2026 18:49
5c72ff8 to
4680860
Compare
Export half of generic function support (swiftwasm#398); the import half landed in swiftwasm#799 and this reuses its ABI: values cross on each type's existing stack layout, and a trailing i32 type ID per generic parameter selects the concrete type, so the per-function glue stays type-agnostic. @js public func identity<T: BridgedSwiftGenericBridgeable>(_ value: T) -> T { value } const n = exports.identity(42, BridgeTypes.Int); // TS erases generics, const p = exports.identity(pt, BridgeTypes.Point); // so callers pass a token How it works: - The wasm entry point is a concrete @_expose thunk taking the trailing type IDs. Because a type ID is the address of the type's BridgeJSTypeHandle, the thunk recovers the concrete type directly via Unmanaged.fromOpaque and reifies T through an opened existential (nested opening chain for multiple parameters); the body then runs the same pop-call-push sequence a concrete export would. - Generic thunks always hoist stack-using arguments: the JS wrapper lowers self and every argument in declaration order, so the pops must run in reverse declaration order ahead of any self pop embedded in the callee expression. - The JS wrapper resolves the caller's BridgeTypes token through the map built during type-handle registration and throws a catchable TypeError for unknown tokens before anything is lowered, so the shared value stack stays balanced. A raw wasm caller passing a garbage type ID is undefined behavior; the generated wrapper is the only supported caller. - Tokens are unqualified type names, so linking two modules that define same-named @js types fails the build while generics are in use. - Generics work on top-level functions and on instance and static methods of @js classes, structs, and enums, with T bare or wrapped as [T], T?, or [String: T]. - Type recovery needs the handle's metatype storage (an existential), so exported generic thunks are fatalError stubs under Embedded Swift; imports remain Embedded-compatible. Unsupported forms (async or throws generics, where clauses, unused generic parameters, concrete non-Void returns, generic initializers, default parameter values, nested wrappings) are rejected with build-time diagnostics.
Codegen and link snapshots pin every distinct thunk shape: bare and wrapped generics, return-only generics, one generic used in several parameters, several distinct generic parameters, concrete parameters mixed in, case-colliding names (T vs t), and generic methods on each owning construct (class, struct, enum, and a method-only module). GenericMethodOnlyModuleCodegenTests pins the runtime-infrastructure gating: a module whose only generic declarations are exports over primitives emits no module registration hook (primitive handles are owned by JavaScriptKit's core hook), while modules with @js types register their own; non-generic builds get no generic runtime at all. ExportGenericAPIs + ExportGenericTests.mjs round-trip every bridgeable type through real JS in both directions, including empty collections, nil, heap-object reference identity, and consecutive calls with different types. The unknown-token TypeError is asserted to be catchable and to leave the shared value stack balanced for the next call.
The same protocol compositions now work on exported generics:
@js public func storeNode<T: BridgedSwiftGenericBridgeable & GraphNode>(_ node: T) -> T { node }
Unlike imports, the Swift compiler cannot check a JavaScript caller, so
the constraint is enforced at the boundary: the link layer records every
@js type's conformed @js protocols (declared on the type or via an
extension) in a token-to-conformances map, and the generated wrapper
resolves the caller's BridgeTypes token against the constraint before
anything is lowered. A non-conforming token throws a catchable TypeError
without entering wasm, keeping the shared value stack balanced; the
Swift-side generic signature then guarantees the constraint holds inside
the thunk. The d.ts wrapper signature carries 'T extends GraphNode', so
TypeScript users get the check at compile time as well.
Generic exported functions and methods may now be throws(JSException),
matching what generic imports already supported:
@js public func pickOrThrow<T: BridgedSwiftGenericBridgeable>(_ value: T, _ fail: Bool) throws(JSException) -> T
The concrete entry thunk wraps the existential-opening call chain in the
same do/catch every throwing export uses — the exception crosses through
the _swift_js_throw side channel — and the open-chain helpers become
throws(JSException) so the typed error propagates without erasure. On
the JavaScript side the wrapper rethrows the exception right after the
wasm call and before lifting the result, so a thrown call reads nothing
back and leaves the shared value stack balanced; the wrappers emitted
through the thunk builder get this from the existing effects-driven
exception check, and the struct-instance method path emits the same
sequence explicitly.
async generic exports remain rejected with a diagnostic. There is no
compiler obstacle — the wasm32 typed-throws closure issues are already
worked around by the forced-capture emission (swiftwasm#760) and JSException
storage boxing (swiftwasm#766) — but promise settlement is per-type today: each
async export settles through a Promise_resolve_<type> helper paired
with a JS settle handler that lifts a concrete value. A generic result
needs a codec-driven settlement path (stash the call's codec with the
promise's settlers, settle through the value stack), which is its own
ABI addition and lands separately.
Runtime tests cover the happy path, the surfaced exception, and that a
thrown call leaves the stacks balanced for the next generic call.
A module may declare that a @js type exported by one of its dependencies conforms to a @js protocol it defines: // ModuleB, depending on ModuleA @js protocol GraphNode { var id: String { get } } extension Building: GraphNode {} // Building is ModuleA's @js struct @js public func store<T: BridgedSwiftGenericBridgeable & GraphNode>(_ n: T) -> T { n } The Swift compiler sees the retroactive conformance directly, so the generic thunk already accepted ModuleA's type - but the JS-side token check did not: the conformance was declared in ModuleB's sources while the token belongs to ModuleA, and neither skeleton recorded it, so a valid call threw a spurious TypeError. Extension conformances whose target does not resolve in-module are now resolved against the external module index and recorded in the skeleton (externalJSProtocolConformances, keyed by the conformer's Swift dot path, e.g. `Models.Site`). The link layer sees every module's skeleton, maps that dot path to the defining module's token (`Models_Site`), merges the record into that token's entry, and the refinement closure applies on top.
krodak
force-pushed
the
kr/generics-export-side
branch
from
September 28, 2026 19:12
4680860 to
d8c198a
Compare
krodak
changed the base branch from
kr/protocol-refinement-upstream
to
main
September 28, 2026 19:12
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Export half of generic function support (swiftwasm#398), on top of the import half (swiftwasm#799) and
@JSprotocol refinement with constrained imports (swiftwasm#815). An exported@JSfunction or method can take a type parameter, and the JavaScript caller selects the concrete type with aBridgeTypestoken:Tmay be any bridgeable primitive,String,JSValue, a@JSstruct, final class, or enum, or a@JSprotocol that inheritsBridgedSwiftGenericBridgeable(its token selects the generated JavaScript-backed wrapper), bare or as[T],T?,[String: T]. Generics work on top-level functions and on instance and static methods of@JSclasses, structs, and enums. Values cross on the existing stack ABI; the only addition on the wire is a trailingi32type ID per generic parameter.1. Type recovery. The type ID is the address of the type's
BridgeJSTypeHandle, so the concrete@_exposethunk recovers the type withUnmanaged.fromOpaqueand reifiesTthrough an opened existential (a nested chain for multiple parameters). No per-module registry. Generic thunks always hoist stack-using arguments so pops run in reverse declaration order ahead of a structselfpop embedded in the callee. Excluded under Embedded Swift (needs the handle's metatype).2.
@JSprotocol constraints on exports.<T: BridgedSwiftGenericBridgeable & P1 & P2>(or<T: P>whenPinherits the bound), reusing swiftwasm#815's constraint parsing. Unlike imports, the Swift compiler cannot check a JavaScript caller, so the constraint is enforced at the boundary: the link layer records every type's@JSprotocol conformances (declared inline, in an extension, or in an extension on a dependency's type), closes them over protocol refinement, and the wrapper validates the token against the constraint before lowering anything. A non-conforming token throws a catchableTypeErrorand the value stack stays balanced. The generated Swift generic clause keeps qualified spellings (GraphKit.Node); the d.ts carriesT extends P1 & P2. Same-named@JSprotocols across linked modules fail the link, mirroring the token rule for types.3.
throws(JSException)generic exports. Same side-channel convention as concrete throwing exports; the wrapper rethrows before lifting.asyncgeneric exports are rejected with a diagnostic: promise settlement is per-type today and a codec-driven settlement path is a separate ABI addition.4. Tokens are branded.
BridgeType<T>uses aunique symbolbrand, so a raw"Int"does not satisfyBridgeType<number>; a call-site fixture undercheck:bridgejs-dtspins that,Tinference, and constraint rejection. Namespaced types use their ABI name as the token (BridgeTypes.API_Building).Unsupported forms produce build-time diagnostics:
async,whereclauses, unused generic parameters, concrete non-Voidreturns, nested wrappings, generic initializers, default parameter values on generic exports, and generic requirements on@JSprotocols.Test plan
TypeErrorpaths with a follow-up call proving stack balance, and a thrown generic call leaving the stacks balancedBridgeJSToolbuild,Examples/Embedded,check:bridgejs-dts, andbridge-js-generate.shdrift check pass; the upstreamswiftbuildlane passes onswift-DEVELOPMENT-SNAPSHOT-2026-08-11-aTracks BridgeJS: Generic exports PR with @JS protocol constraints.