Skip to content

BridgeJS: Support generic functions on exported Swift APIs - #24

Draft
krodak wants to merge 5 commits into
mainfrom
kr/generics-export-side
Draft

krodak wants to merge 5 commits into
mainfrom
kr/generics-export-side

Conversation

@krodak

@krodak krodak commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Overview

Export half of generic function support (swiftwasm#398), on top of the import half (swiftwasm#799) and @JS protocol refinement with constrained imports (swiftwasm#815). An exported @JS function or method can take a type parameter, and the JavaScript caller selects the concrete type with a BridgeTypes token:

@JS public func store<T: BridgedSwiftGenericBridgeable & GraphNode>(_ node: T) -> T { node }
import { BridgeTypes } from "./bridge-js.js";
const b = exports.store({ id: "b1", floors: 3 }, BridgeTypes.Building);   // store<T extends GraphNode>

T may be any bridgeable primitive, String, JSValue, a @JS struct, final class, or enum, or a @JS protocol that inherits BridgedSwiftGenericBridgeable (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 @JS classes, structs, and enums. Values cross on the existing stack ABI; the only addition on the wire is a trailing i32 type ID per generic parameter.

1. Type recovery. The type ID is the address of the type's BridgeJSTypeHandle, so the concrete @_expose thunk recovers the type with Unmanaged.fromOpaque and reifies T through 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 struct self pop embedded in the callee. Excluded under Embedded Swift (needs the handle's metatype).

2. @JS protocol constraints on exports. <T: BridgedSwiftGenericBridgeable & P1 & P2> (or <T: P> when P inherits 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 @JS protocol 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 catchable TypeError and the value stack stays balanced. The generated Swift generic clause keeps qualified spellings (GraphKit.Node); the d.ts carries T extends P1 & P2. Same-named @JS protocols 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. async generic 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 a unique symbol brand, so a raw "Int" does not satisfy BridgeType<number>; a call-site fixture under check:bridgejs-dts pins that, T inference, and constraint rejection. Namespaced types use their ABI name as the token (BridgeTypes.API_Building).

Unsupported forms produce build-time diagnostics: async, where clauses, unused generic parameters, concrete non-Void returns, nested wrappings, generic initializers, default parameter values on generic exports, and generic requirements on @JS protocols.

Test plan

  • Codegen and link snapshots for every thunk shape, including generic methods on each owning construct, renamed and namespaced members, throwing generics, constraint compositions, and bridgeable-protocol tokens
  • Diagnostics suites, including qualified external constraints and legacy skeleton decoding
  • WASM runtime round-trips of every bridgeable type, constrained round-trips including refinement, the unknown-token and non-conforming-token TypeError paths with a follow-up call proving stack balance, and a thrown generic call leaving the stacks balanced
  • Root BridgeJSTool build, Examples/Embedded, check:bridgejs-dts, and bridge-js-generate.sh drift check pass; the upstream swiftbuild lane passes on swift-DEVELOPMENT-SNAPSHOT-2026-08-11-a

Tracks BridgeJS: Generic exports PR with @JS protocol constraints.

@krodak
krodak force-pushed the kr/generics-export-side branch 3 times, most recently from 6463336 to 317896e Compare September 22, 2026 09:00
@krodak
krodak force-pushed the kr/generics-export-side branch from 317896e to 5c72ff8 Compare September 22, 2026 14:22
@krodak
krodak changed the base branch from main to kr/protocol-refinement-upstream September 22, 2026 14:22
@krodak
krodak force-pushed the kr/generics-export-side branch from 5c72ff8 to 4680860 Compare September 28, 2026 18:49
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
krodak force-pushed the kr/generics-export-side branch from 4680860 to d8c198a Compare September 28, 2026 19:12
@krodak
krodak changed the base branch from kr/protocol-refinement-upstream to main September 28, 2026 19:12

This branch has not been deployed

No deployments
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