Skip to content

BridgeJS: Support generic functions at the Swift and JavaScript boundary#14

Closed
krodak wants to merge 2 commits into
mainfrom
kr/stack-abi-generics-export
Closed

BridgeJS: Support generic functions at the Swift and JavaScript boundary#14
krodak wants to merge 2 commits into
mainfrom
kr/stack-abi-generics-export

Conversation

@krodak

@krodak krodak commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Overview

Adds support for generic functions and methods to BridgeJS in both directions, constrained to a new bridgeable bound _BridgedSwiftGenericBridgeable. Values cross the boundary using each type's existing stack ABI, and a runtime type-ID registry selects the correct per-type codec, so the per-function glue stays type-agnostic. The @JS / @JSFunction macros are unchanged.

The bound is _BridgedSwiftStackType refined with StackLiftResult == Self (so a generic thunk can pop a T, which also structurally excludes JSObject) plus a single bridgeJSTypeID: Int32 requirement. That ID is the runtime identity handshake: each type's token string is interned through one wasm import exactly once (a lazy static let), after which both sides exchange plain i32s; name-based interning is what keeps IDs consistent across independently compiled modules.

// Import: call a generic JS function from Swift
@JSFunction func parse<T: _BridgedSwiftGenericBridgeable>(_ json: String) -> T
let user: User = try parse(jsonString)

// Export: call a generic Swift function from JavaScript
@JS public func identity<T: _BridgedSwiftGenericBridgeable>(_ value: T) -> T { value }
import { BridgeTypes } from "./bridge-js.js";
const n = exports.identity(42, BridgeTypes.Int);       // token recovers T, since TS erases generics
const p = exports.identity({ x: 1, y: 2 }, BridgeTypes.Point);

T may be a supported primitive (Bool, any fixed-width integer, Float, Double, String, or JSValue), or any @JS struct, final @JS class, or @JS enum defined in the same module. It may be used bare (T) or wrapped as [T], T?, or [String: T]. JSObject cannot be used as T; use JSValue instead.

What's included

1. Import direction. Generic @JSFunction thunks (top-level or @JSClass members) are generic and monomorphized by the Swift compiler at the call site. One type-agnostic wasm import carries a trailing type-ID per generic parameter, and JS dispatches through a shared codec table. Supports a generic used in one or many parameters, multiple distinct generic parameters, and return-only generics (make<T>() -> T).

2. Export direction. Generics work on top-level functions, instance and static methods of @JS classes and structs, and static methods of @JS enums. The wasm entry point is a concrete @_expose/@_cdecl thunk taking a trailing type-ID per generic parameter; the generic value crosses on the stack, and self is threaded through the existential open-chain for instance methods. The thunk looks the type-ID up in a codegen-emitted registry and reifies T through an opened existential (nested opening chain for multiple parameters). Concrete parameters of any supported bridged type may be mixed in with correct stack ordering. JavaScript callers pass a generated BridgeType<T> token, exported as a BridgeTypes map. Because this relies on opened existentials, the exported thunk is emitted as a fatalError stub under #if hasFeature(Embedded); the import side stays Embedded-compatible.

3. Codecs and wrapped generics. Each codec ({ lower, lift }) is generated from the canonical per-type stack fragments (stackLowerFragment / stackLiftFragment), the same path the non-generic glue uses, so there is no duplicated lowering to drift. [T], T?, and [String: T] bridge through the existing Array/Optional/Dictionary conditional conformances on the Swift side, exactly like concrete types; in particular, numeric arrays keep the bulk typed-array fast path (the JS composition helper honors the existing -1 bulk discriminator). This builds on the optional stack encoding unification (previous PR in this series), which made the optional stack form uniform across all types.

4. Diagnostics. Build-time, source-located diagnostics for unsupported forms: missing or incorrect constraint, where clauses, async or throws generics, generics in @JSClass or static members, unsupported wrappings ([[T]], [T?], [Int: T]), and an export return type that is neither a declared generic (optionally wrapped) nor Void. The linker also fails the build when two linked modules define same-named @JS types while generics are in use, since generic tokens are unqualified type names.

5. Documentation. DocC articles (exporting/importing functions, supported types, unsupported features, internals rationale) and the BridgeJS README bridged-type table.

Testing

  • Codegen snapshot fixtures for both directions pin every distinct thunk shape: bare and wrapped generics, return-only generics, a generic used in multiple parameters, multiple distinct generic parameters, concrete parameters mixed with the generic, case-colliding names (T vs t), and generic methods on each owning construct (class, struct, enum, namespace enum, @JSClass).
  • Diagnostics tests pin each build-time rejection listed above.
  • WebAssembly runtime tests round-trip every supported T in both directions, including the [T]/T?/[String: T] wrappers with empty collections and nil, bulk numeric arrays, heap-object reference semantics, consecutive calls with different types, and generic instance/static methods on classes, structs, and enums.
  • Linker tests verify that modules without generics produce unchanged output and that duplicate type tokens across linked modules fail the build.

Open questions

  • The generic infrastructure (conformances, codec table, BridgeTypes, resolver, type registry) is only emitted for modules that actually declare generics. I considered dropping the gating to streamline the codegen, but the gates themselves are a handful of trivial conditionals, while removing them would regenerate every snapshot fixture and grow the generated output of every module that never uses generics. If you would rather have unconditional emission for simplicity, I am happy to remove the gating.

Addresses swiftwasm#398

@krodak krodak force-pushed the kr/stack-abi-generics-export branch 9 times, most recently from c6c78a4 to 4ad8122 Compare June 17, 2026 13:57
@krodak krodak force-pushed the kr/stack-abi-generics-export branch 7 times, most recently from 765ecd2 to f19341a Compare July 8, 2026 17:53
@krodak krodak changed the base branch from main to kr/unify-optional-stack-encoding July 8, 2026 17:54
@krodak krodak force-pushed the kr/stack-abi-generics-export branch 4 times, most recently from 42c2670 to 6e3da66 Compare July 8, 2026 19:45
@krodak krodak changed the base branch from kr/unify-optional-stack-encoding to main July 8, 2026 20:12
@krodak krodak force-pushed the kr/stack-abi-generics-export branch from 6e3da66 to 50bcb2c Compare July 8, 2026 20:12
@krodak krodak closed this Jul 9, 2026
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.

1 participant