Skip to content

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

Open
krodak wants to merge 5 commits into
swiftwasm:mainfrom
PassiveLogic:kr/stack-abi-generics-export
Open

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

Conversation

@krodak

@krodak krodak commented Jul 8, 2026

Copy link
Copy Markdown
Member

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 and is now exercised end-to-end by Examples/Embedded (generic @JSFunction round-tripping scalars, String, and a @JS struct).

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 #398

@krodak krodak self-assigned this Jul 8, 2026
@kateinoigakukun kateinoigakukun self-requested a review July 9, 2026 09:20

@kateinoigakukun kateinoigakukun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for tacking on this! I now feel like we still need some reorganization, which likely requires substantial design work to avoid complicating the codegen and ABI implementation.

if let enumHelpers = enumCodegen.renderEnumHelpers(enumDef) {
decls.append(enumHelpers)
}
if hasGenerics, enumDef.enumType != .namespace {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if hasGenerics, enumDef.enumType != .namespace {
if enumDef.enumType != .namespace {

I think "If a module contains any generic declaration" and "a type defined in a module can be used to substitute a generic type parameter" are unrelated. (e.g. Given a type X, which is defined in module A that does not have any generic declaration, module B should be able to pass type X to @JS func take<T>(_: T) defined in B.

let structCodegen = StructCodegen()
for structDef in skeleton.structs {
decls.append(contentsOf: structCodegen.renderStructHelpers(structDef))
if hasGenerics {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ditto

for function in skeleton.functions {
decls.append(try renderSingleExportedFunction(function: function))
}
if hasGenericExports {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ditto

Comment on lines +674 to +682
if function.isGeneric {
if function.effects.isStatic, let staticContext = function.staticContext {
return try GenericExportCodegen().renderExportedFunction(
function,
selfKind: .staticMember(staticContextBaseName(staticContext))
)
}
return try GenericExportCodegen().renderExportedFunction(function)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's avoid duplicating the entire call emission flow just for generics. I think most of the generics -specific stuff should be handled inside ExportedThunkBuilder without having a fresh new liftParameter -> call -> lowerReturnValue build sequence.

ownerTypeName: String,
instanceSelfType: BridgeType
) throws -> DeclSyntax {
if method.isGeneric {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ditto on my code duplication comment above

// Generate ConvertibleToJSValue extension
decls.append(contentsOf: renderConvertibleToJSValueExtension(klass: klass))

if hasGenerics, klass.isFinal == true {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ditto on my generic-availability comment above.

" }",
" return id;",
"}",
"function __bjs_lowerArrayGeneric(value, codec) {",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, I feel this part is a kind of yellow sign of this approach. In the ideal case, the stack ABI of generic containers should be described once, and optionally instantiated with concrete type parameters. Maybe we need to revisit formalizing how we describe each type's stack ABI convention (like defining with DSL) so that we don't need to make this kind of code clone.

printer.write("extension \(typeName): BridgedSwiftGenericBridgeable {")
printer.indent {
printer.write(
"@_spi(BridgeJS) public static let bridgeJSTypeID: Int32 = _swift_js_resolve_type_id(\"\(abiName)\")"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it'd be better to avoid string-based identification since it's fragile for cross-module declaration name conflicts. I'm thinking something like this.

final class BridgeJSTypeHandle {
    let type: any BridgedSwiftGenericBridgeable.Type
    init(_ type: any BridgedSwiftGenericBridgeable.Type) { type = type }
    var typeID: UInt32 { Unmanaged.passUnretained(self).toOpaque() }
}
extension Point {
    static let _bridgeJSTypeHandle = BridgeJSTypeHandle(Point.self)
}

@_export("bjs_TestModule_type_handles")
func bjs_TestModule_type_handles() {
    withUnsafeTemporaryAllocation(of: UnsafeMutableRawPointer.self, capacity: NUM_OF_TYPES) { buf in
        buf[0] = Point._bridgeJSTypeHandle.typeID
        // ...
        // Build lookup table in JS side keyed by type ID.
        bjs_TestModule_register_type_handles(buf.baseAddress!, NUM_OF_TYPES)
    }
}
const STACK_HANDLE_BY_TYPEID = {}
imports["TestModule"]["bjs_TestModule_register_type_handles"] = function(base, count) {
    // JS side stack operation table ordered in the same way as bjs_TestModule_type_handles's buffer
    const STACK_HANDLES = [
        { push(...) { ... }, pop() { ... } },
        { push(...) { ... }, pop() { ... } },
        ...
    ]
    const typeIDs = new Uint32Array(instance.exports.memory.buffer, base);
    for (let i = 0; i < count; i++) {
        STACK_HANDLE_BY_TYPEID[typeIDs[i]] = STACK_HANDLES[i]
    }
}

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