Skip to content

BridgeJS: Export types using a separate JS representation#750

Merged
krodak merged 3 commits into
swiftwasm:mainfrom
wfltaylor:js-as
Jun 24, 2026
Merged

BridgeJS: Export types using a separate JS representation#750
krodak merged 3 commits into
swiftwasm:mainfrom
wfltaylor:js-as

Conversation

@wfltaylor

Copy link
Copy Markdown
Contributor

Note that this PR is much smaller than it looks! The vast majority of the lines added are tests/generated code.

Adds @JS(as:) to let types provide an alternative JavaScript representation, providing an escape hatch when BridgeJS’s defaults don't fit.

Motivation

BridgeJS provides sensible defaults when exporting Swift code to JavaScript. For example, structs are exported using copy semantics: each field in the Swift struct is copied when crossing the boundary. BridgeJS also only supports exporting a subset of Swift features, which makes sense given there are many things in the Swift language which would be difficult to expose to JavaScript.

While this approach makes sense, sometimes the defaults used by BridgeJS won’t work well in a specific situation. For example, a Swift struct Polygon { var vertices: [Point] } will be exported to JavaScript using copy semantics. In a project where polygons could contain thousands of points, copying at the boundary every time provides unacceptable performance. Currently, there is no escape hatch for situations like these. Either the Swift code has to be modified to be less idiomatic (e.g. in this case by making Polygon a class), or a duplicate type could be created (say a JSPolygon) and exposed to JavaScript that wraps the underlying Swift type. A similar situation could be encountered when using a feature which BridgeJS doesn’t support, for example dictionaries with integer keys. The same two options are available: less idiomatic Swift code (using String keys everywhere), or duplicating types.

At first, the “duplicated types” approach sounds reasonable. Unfortunately, it starts to break down as the project scales. For example, a large existing codebase could use Polygon in hundreds of types and hundreds of methods. Creating a duplicate JS hierarchy here introduces a huge maintenance cost, where most of the duplication is providing no value since BridgeJS’s defaults likely make sense the majority of the time. The same situation occurs with unsupported features: a leaf type might introduce a dictionary with integer keys, requiring the creation of a vast hierarchy of duplicated JS-safe types.

Solution

Allow types to provide an alternative JS representation. This works as follows:

@JS(as: JSPolygon.self) public struct Polygon {

    public var vertices: [Point]

    public consuming func bridgeToJS() -> JSPolygon {
        JSPolygon(underlying: self)
    }

    public static func bridgeFromJS(_ value: consuming JSPolygon) -> Polygon {
        value.underlying
    }

}

@JS public final class JSPolygon {

    var underlying: Polygon

    @JS public init(underlying: Polygon) {
        self.underlying = underlying
    }

    @JS var boundingBox: Rect { underlying.boundingBox }

}

Now, the existing Swift code can continue to use the Swift-idiomatic Polygon while JavaScript gets the appropriate JSPolygon type.

Design

This is implemented by inserting calls to bridgeToJS() and bridgeFromJS at the first and last possible opportunity respectively. This means that code generation changes are minimal, everything just uses the ABI of the JavaScript type. I’ve added a new case to BridgeType indirect case alias(name: String, underlying: BridgeType). I’m very much not sold on the name alias, but I couldn’t think of anything better.

Alternatives

  • PR BridgeJS: Add support for exporting structs using a box #740 aimed to solve a similar problem, but was more narrow (and required more complex changes). While this approach requires more boilerplate for something like the Polygon case, it is also more general.
  • In Support exporting structs as a reference #737, @krodak suggested using a custom code-generation tool. While this is a possible approach, implementing this correctly is tricky and it makes sense to me for BridgeJS to have built-in support for what is likely going to be a common problem.
  • Not doing anything: the status quo of requiring users to modify their Swift code to be less idiomatic or create a duplicate hierarchy of types. This would likely be an impediment to adopting BridgeJS, especially in larger projects who may have more consumers than just JavaScript.

@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.

Thank you for trying to tackle this and showing your idea with actual code! This is very interesting to me, and I just realized the ability to have different transferring representations solves some other problems like #496

Comment thread Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

@krodak krodak 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.

LGTM, just minor requests

Comment thread Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift Outdated
Comment thread Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift Outdated
Comment thread Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift Outdated
Builtin.ExternalMacro
public macro JS(
as aliasOf: Any.Type? = nil,
namespace: String? = nil,

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.

@JS(as:) is a meaningful new escape hatch and _BridgedSwiftAlias is public API, but there's no DocC article for it. Every other exporting capability has one under Documentation.docc/Articles/BridgeJS/Exporting-Swift/ (Class, Static-Functions, Static-Properties...), and that's where people will look first. Worth one even while the feature is experimental - a stub to flesh out:

Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Custom-Representation.md
- When to reach for @JS(as:) (copy-semantics cost, unsupported features)
- The bridgeToJS() / bridgeFromJS contract
- The Polygon / JSPolygon example from the PR description
- Supported underlying representations + current limits (no raw-value enums, protocols, chained aliases)

}

private func recordAlias(
node: some SyntaxProtocol & NamedDeclSyntax,

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.

extractAliasTarget short-circuits before namespace handling, so @JS(as: X.self, namespace: "Foo") silently drops the namespace. Since the macro accepts both arguments, a user could reasonably expect the alias to land in Foo. A diagnostic beats the silent drop - something like, at the top of recordAlias:

if parent.extractNamespace(from: jsAttribute) != nil {
    errors.append(DiagnosticError(
        node: node,
        message: "`namespace` is ignored on `@JS(as:)` types",
        hint: "Remove the `namespace:` argument; the alias adopts its target's representation"
    ))
}

(adjust to whatever namespace-extraction helper is in scope here)

let base = memberAccess.base
else {
return nil
}

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.

If as: is present but not in X.self form, this returns nil and the declaration quietly falls through to normal @JS handling rather than reporting the malformed as:. Low odds given the Any.Type? signature, but it'd save someone a confusing "why isn't my alias working" later. Worth splitting "no as:" from "as: present but unparseable":

guard let asArg = arguments.first(where: { $0.label?.text == "as" }) else {
    return nil  // genuinely not an alias
}
guard let memberAccess = asArg.expression.as(MemberAccessExprSyntax.self),
    memberAccess.declName.baseName.text == "self",
    let base = memberAccess.base
else {
    // `as:` present but malformed - report instead of silently ignoring
    return nil
}
return TypeSyntax(stringLiteral: base.trimmedDescription)

Surfacing a diagnostic from here means threading an errors channel in, so this is a "if you think it's worth it" rather than a must.

// Dictionary mangling: "SD" prefix followed by value type (key is always String)
return "SD\(valueType.mangleTypeName)"
case .alias(let name, _):
return "Al\(name.count)\(name)"

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.

Aliases mangle on name only (Al<count><name>) while the other cases fold in structural info. Fine as long as name stays the namespace-qualified swiftCallName and unique across modules - worth a one-liner so nobody later "simplifies" it to the bare type name:

case .alias(let name, _):
    // `name` is the namespace-qualified swiftCallName (unique); the underlying
    // representation isn't folded in because aliases bridge via their JS type's ABI.
    return "Al\(name.count)\(name)"

@krodak krodak requested a review from kateinoigakukun June 23, 2026 12:02

@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.

LGTM as long as @krodak's feedbacks are resolved.

wfltaylor and others added 3 commits June 24, 2026 14:07
Follow-up to the review on swiftwasm#750:

- ImportTS: give the unreachable `.alias` cases in loweringParameterInfo /
  liftingReturnInfo a message stating the `.unaliased` invariant, so a future
  change that breaks it fails loudly instead of trapping silently.
- JSGlueGen: in `optionalConvention` and `wasmParams`, delegate `.alias` to its
  underlying type rather than `preconditionFailure()`. These switch on `self`
  (not `.unaliased`), so this removes a latent crash for alias-wrapped values
  and matches how abiReturnType / mangleTypeName already handle `.alias`.
- SwiftToSkeleton: diagnose `@JS(as:)` combined with `namespace:` instead of
  silently dropping the namespace; an alias adopts its representation's
  placement. Adds a diagnostics test.
- BridgeJSSkeleton: note why alias mangling uses the (unique) swiftCallName only.
- Docs: add an "Exporting a Type With a Custom JS Representation" article and
  link it from the exporting topics.
@krodak

krodak commented Jun 24, 2026

Copy link
Copy Markdown
Member

@wfltaylor hope you don't mind - I pushed a small commit to your branch to fold in the review feedback, and rebased on main so the snapshots line up with the recent changes there.

What I added:

  • messages on the unreachable .alias precondition cases in ImportTS
  • delegating the .alias cases in optionalConvention/wasmParams to the underlying type instead of trapping
  • a diagnostic for @JS(as:) combined with namespace: (it was silently dropped before)
  • a DocC article for the feature

I wanted to get it green so we can merge and start using it on our side (the @JS(as: String.self) shape is exactly what we need to drop a UUID shim). Thank you so much for the contribution, this is a really nice addition.

@krodak krodak enabled auto-merge June 24, 2026 12:24
@krodak krodak merged commit 3b4bba1 into swiftwasm:main Jun 24, 2026
16 checks passed
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.

4 participants