Skip to content

feat(swift-interop): Any, UUID and Decimal, performance, and docs (Swift interop 7/7) - #517

Draft
NathanWalker wants to merge 8 commits into
swift-interop/6-lifetime-asyncfrom
feat/swift-metadata-gen
Draft

NathanWalker wants to merge 8 commits into
swift-interop/6-lifetime-asyncfrom
feat/swift-metadata-gen

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Swift interop stack — review bottom-up; each PR shows only its own commits. Merge in order; after a squash merge, rebase the next branch onto main. Design discussion: RFC NativeScript/rfcs#57.

  1. feat(metadata-generator): Swift symbol graphs to thunks and TypeScript declarations (Swift interop 1/7) #518 generator: symbol graphs → IR → thunks + .d.ts
  2. feat(runtime): ns:swift calls Swift through generated thunks (Swift interop 2/7) #519 runtime: ns:swift core
  3. feat(swift-interop): modules, generics, protocols, Observation and SwiftUI (3/7) #520 modules, generics, protocols, Observation, SwiftUI
  4. feat(swift-interop): generate and link thunks in app builds (4/7) #521 app builds
  5. feat(swift-interop): returning closures, generic functions, tuples, Result and C structs (5/7) #522 more values: returning closures, generic functions, tuples, Result, C structs
  6. feat(swift-interop): adapter lifetime, cancellation, subscripts, Combine and operators (6/7) #523 adapter lifetime, cancellation, subscripts, Combine, operators
  7. feat(swift-interop): Any, UUID and Decimal, performance, and docs (Swift interop 7/7) #517 Any/UUID/Decimal, performance, docs ← this PR

The top of the stack: the last value types, performance work and documentation.

SwiftDemo.echoAny({ id: 7, tags: ["a"] });  // Any / [String: Any] as JSON-like values
SwiftDemo.halve("9.99");                    // Decimal and UUID as strings, Character as a one-character string
  • Thunk ABI 16.
  • Matched benchmarks: TNSBenchmark (Objective-C, the metadata path) and Benchmark (Swift) implement the same API.
  • Callbacks made on the JS thread convert their arguments straight to JS values through a pooled frame.
  • docs/swift-interop.md adds a quick start in an app, recipes, the performance table, and startup and size figures.

The full feature overview is below.

Whole-stack overview

Summary

This PR lets JavaScript call Swift-only APIs. The iOS runtime can only reach Objective-C today, so these APIs are invisible to it.

The metadata generator now reads Swift symbol graphs for one or more modules and writes two things:

  • Swift thunks that call each declaration with static types;
  • TypeScript declarations for exactly that surface.

A new ns:swift builtin loads the thunks at runtime.

build time (an app's link step, or the generator directly)
  swift-symbolgraph-extract            → symbol graphs per module
  objc-metadata-generator --swift-unit
    SymbolGraphLoader + mergeUnit      → one Swift IR for all modules (streaming; SwiftUI in ~1.2 GB)
    instantiateGenerics                → concrete instantiations (Forecast<DayWeather>, ...),
                                         generic functions per stand-in (largest<Double>, weather<CurrentWeather>, ...)
    CapabilityClassifier               → tier + reasons per declaration
    ProbeVerifier (--swift-verify)     → swiftc type-checks probes and thunks, demotes what it rejects
    SwiftSurface
      ThunkEmitter                     → <Unit>+NativeScript.swift (compiled and linked into the app)
      SwiftDefinitionWriter            → swift!<Module>.d.ts per module

run time
  require("ns:swift/WeatherKit")
    dlsym ns_swift_register_WeatherKit → the unit's types, members, signatures (ABI-versioned)
    per isolate: JS classes mirroring Swift inheritance, one per generic family
    call → 24-byte argument slots → thunk → typed Swift call → result entries

From JavaScript, this looks like:

const SwiftDemo = require("ns:swift/SwiftDemo");

new SwiftDemo.Demo().greet("NativeScript");        // "Hello, NativeScript"
await SwiftDemo.delayedEcho("later", 0.1);         // async → Promise, settled on the calling isolate
SwiftDemo.measure(2, 3);                           // { case: "area", width: 2, height: 3 }
SwiftDemo.countdown(3, (value, last) => {});       // closures take JS functions
SwiftDemo.introduce({ name: "js", punctuation: "?", greet: (o) => "hi " + o, reset() {} }, "ada");
for await (const n of SwiftDemo.countUp(3)) {}     // AsyncStream → async iterator
new Kit.Pair(1, "one").swapped();                  // generics: one JS class, instantiation by arguments
swift.observe(() => thermostat.celsius, update);   // @Observable tracking
new SwiftDemo.Badge("Inbox", 3).hostingController(); // SwiftUI view → UIViewController
SwiftDemo.transform([1, 2], (n) => "#" + n);       // closures returning values: ["#1", "#2"]
SwiftDemo.largest(["b", "c"]);                     // generic functions: overloads per stand-in
service.weather(location, WeatherQuery.daily);     // → Promise<Forecast<DayWeather>>
SwiftDemo.parseNumber("x");                        // Result: { case: "failure", value: SwiftError }
SwiftDemo.divide(7, 2);                            // labeled tuple: { quotient: 3, remainder: 1 }
SwiftDemo.midpoint({ x: 0, y: 0 }, CGPointMake(4, 2)); // C structs as plain objects: { x: 2, y: 1 }
CounterView.new().configure(1, null);             // Swift-only members of a Swift UIView subclass, on NativeScript's class
await SwiftDemo.waitForCancel(10, controller.signal); // AbortSignal rejects and cancels the Swift task
shelf.setSubscript("apples", 3); shelf.subscript("apples"); // subscripts
a.plus(b); versions.sort((x, y) => x.compare(y));  // operators, Equatable/Comparable
SwiftDemo.sumAll([1, 2, 3]);                       // some Sequence<Int>, C: Collection, any Sequence<E> take arrays
SwiftDemo.echoAny({ id: 7, tags: ["a"] });         // Any / [String: Any] as JSON-like values

What crosses today:

  • Values: primitives (with range-checked integers), String, Character, optionals, Date, URL, UUID and Decimal (strings), Data, Any and [String: Any] (JSON-like values), arrays and [String: T] (optional elements as nulls), Set (a JS Set), tuples (arrays, or objects when labeled), any Error (a SwiftError), Result ({ case: "success" | "failure", value }), and CoreGraphics/UIKit/CoreLocation structs such as CGRect and CLLocationCoordinate2D (plain objects).
  • Swift types: classes and actors (one JS identity, released on GC), structs, and enums with or without associated values (the latter as tagged objects).
  • Generics: generic types through the concrete instantiations the code spells, plus public typealiases. Generic functions get one overload per concrete stand-in: Double/String/Bool (Int for integer protocols), any P for a unit protocol, the superclass, or the arguments of the unit's instantiations of a generic type they take (WeatherQuery<T>).
  • Operators and comparisons: operators as named methods (plus, minus, times, equals, lessThan, negated, ...), and equals() / compare() on Equatable / Comparable types.
  • Collection protocols: some Sequence<Int>, C: Collection where C.Element == X and any Sequence<E> take JS arrays.
  • Calls: async functions (cancellable with a trailing AbortSignal), actor and @MainActor isolation, closures (including ones that return values or throw, waited for from any thread), inout parameters, subscripts (subscript() / setSubscript()), Objective-C objects (as NativeScript's own wrappers), and JS implementations of Swift protocols (one adapter per JS object, so Swift sees one identity; held by NativeScript's rule for the JS side of Objective-C objects, so weak var delegate works while JS keeps the delegate).
  • Objective-C-visible Swift classes: Swift-only members of NSObject / UIView subclasses install on NativeScript's own class (instance members on its prototype, Swift-only initializers as init), never replacing NativeScript's members.
  • Async and observation: async sequences (including types conforming to AsyncSequence, and Combine publishers through values; leaving a for await loop early ends the Swift stream), @Observable tracking, and SwiftUI view hosting.
  • Modules: types and extensions work across the modules of a generation unit.
  • Errors and availability: a Swift throw becomes a JS SwiftError, and declarations newer than the running OS throw instead of crashing.

App builds: an app lists the modules JS uses in App_Resources/iOS/build.xcconfig, as NS_SWIFT_MODULES = WeatherKit MyPackage. The template's nsld.sh then generates, verifies, compiles and links the thunks, so the CLI needs no change.

docs/swift-interop.md has the full semantics, and NativeScript/runtime/SwiftInterop.h is the binary contract.

Evidence

  • Before: require("ns:swift") doesn't exist. Swift-only declarations aren't reachable at all.
    After:
    • TestRunner: 1978 specs, 0 failures. 143 of them exercise ns:swift against the SwiftDemo and SwiftDemoKit fixtures linked into TestRunner. They cover worker isolates, background-thread callbacks and protocol calls, GC release, generics and generic functions, Result, tuples, sets, C structs, Objective-C-visible classes, weak delegates, cancellation, subscripts, AsyncSequence types, Combine publishers, Observation and async iteration.
    • Generator tests: 69 tests, 0 failed checks. They include snapshots of the IR, coverage, probes, thunks and .d.ts, plus a tsc pass/fail usage file.
    • Objective-C metadata output is unchanged.
  • SDK scale: each module was run with --swift-verify against the iOS 27.1 simulator SDK (deployment target 18.0), and every generated thunk file type-checks with 0 errors.
Module Callable through thunks Of Compiler-demoted
Foundation 3861 18289 12
SwiftUI 1043 85975 8
WeatherKit 424 1616 9
SwiftData 241 1102 3
Charts 140 10917 4
Observation 24 82 5
RegexBuilder 24 512 1

A generic function counts once, as callable when any of its instantiations is. WeatherKit's weather(for:including:) has 8 typed overloads, one per WeatherQuery instantiation.

  • Benchmarks (Release, iPhone 17 Pro simulator): SwiftInteropBenchmarks.js calls one API implemented twice, in Objective-C (TNSBenchmark, the metadata path) and in Swift (Benchmark, ns:swift), and consumes every result in JS. Swift / Objective-C time:

    Workload Ratio
    scalar calls (no args, int, 3 doubles, bool) 0.57x – 0.93x
    strings (short, 10 KB in, 10 KB round trip) 0.69x / 0.66x / 0.86x
    object construct / method / property read 0.41x / 1.16x / 0.92x
    1000-int array in / out 0.32x / 0.31x
    10-key dictionary in / out 0.36x / 0.39x
    1 MB Data in / out 0.12x / 0.68x
    CGPoint in and out 1.02x
    build, filter, sort 1000 records, read all 0.28x
    async call and await (work off the main thread, result on it) 0.82x
    thrown error 0.86x
    native calls a JS function / JS object 100 times 0.82x / 1.08x

    Callbacks made on the JS thread convert their arguments straight to JS values (a pooled frame, no per-argument allocation); callbacks from other threads are recorded and run on the JS thread. docs/swift-interop.md has the full table and method.

  • App build: a real app (its own Swift module, WeatherKit, a local SwiftPM package and Combine) built through ns build ios with the packed runtime. Debug and Release simulator builds pass a startup check of every feature (22 checks at ABI 16), and Release keeps the register symbols visible to dlsym. The checks cover value-returning closures, generic functions, WeatherKit's generic weather(for:including:), a Swift UIView subclass reached through the app's -Swift.h metadata, subscripts, AbortSignal cancellation, Any payloads and Combine publishers. In Release the thunk object for the app's module, WeatherKit (424 callables) and the package is 0.8 MB, and the first ns:swift import takes 2.7 ms. An unchanged module reuses its cached thunk object on relink.

Still to come (draft)

  • Generic methods of generic types (their own type parameters mostly appear only in results or closures, which arguments cannot choose).
  • Protocols with associated types as values or JS implementations, beyond the collection protocols.
  • Swift overlay members of SDK Objective-C classes (such as NSData's), which NativeScript's metadata already covers in Objective-C form.
  • Benchmarks on a physical device.

Evidence

  • Before: callbacks into JS ran at 1.97x and protocol calls at 2.59x of the Objective-C time.
    After: 0.82x and 1.08x. The remaining workloads in the overview's table run at 0.12x–1.16x.
  • TestRunner: 1978 specs, 0 failures (143 ns:swift, including benchmarks).
  • Generator tests: 69, 0 failed checks.
  • App build: 22 startup checks pass at ABI 16 in Debug and Release.
  • SDK sweep: generated thunks type-check with 0 errors for Foundation, SwiftUI, WeatherKit, SwiftData, Charts, Observation and RegexBuilder.

Merge Danger

Door: two-way until release, then one-way for the ABI

The runtime refuses thunks generated for another ABI version, so mismatches fail loudly. Once a runtime ships with ABI 16, apps' generated thunks depend on it.

Blast Radius: runtime-wide (opt-in)

Nothing runs until JS imports ns:swift. Apps without NS_SWIFT_MODULES build as before.

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…tics named name/length

SwiftInteropBenchmarks.js now calls one API implemented twice and
consumes every result in JS: TestFixtures' TNSBenchmark in Objective-C
(the metadata path) and SwiftDemo's Benchmark in Swift (ns:swift). The
cases cover:
- calls of several shapes, short and 10 KB strings;
- objects (construction, methods, properties);
- 1000-element arrays and 10-key dictionaries in both directions;
- CGPoint, native calls into JS functions and delegates;
- errors, a 1000-record build/filter/sort batch, and async calls.

In a Release TestRunner on the iPhone 17 Pro simulator (iOS 26), the
Swift path takes 0.6 to 0.9 of the time for scalar calls, 0.42 for
construction, about a third for collections, 0.27 for the batch and
0.15 for async calls. Native code calling a JS function is about 2x
slower (0.2 vs 0.1 us per call). docs/swift-interop.md has the full
table and method.

Callbacks and delegate calls made on the JS thread now run directly,
without locking or queueing. They also use the current context.

A Swift static member named `name` or `length` crashed module loading:
V8 aborts when a function template redefines its own built-ins. Those
members are now defined on the class after it exists.
Loading WeatherKit's thunks aborted V8. A generic type whose
instantiations register the same overload (WeatherKit's Forecast `==`,
or a static whose signature does not involve the type's arguments)
produced the same overload key twice on one JS class template, and V8
aborts when a template defines a property twice.

The fix:
- overload keys are now unique per group;
- the generator drops a member that duplicates another's owner, kind,
  Swift name and signature, since graphs can declare one member twice;
- a call that matches only identical overloads takes the first instead
  of reporting it as ambiguous.

The SwiftDemoKit fixture's Box.make has one signature on every
instantiation and covers this case.
…I 16)

`Any` now crosses as whatever JS value fits:
- null as NSNull, booleans, integral numbers as Int and others as
  Double, strings, arrays as [Any], and plain objects as [String: Any];
- Swift values of the unit's types and Objective-C objects as
  themselves.

Swift reports Any back by the value's type. Foundation's bridged
NSNumbers keep booleans apart from numbers. The unit's own classes,
structs and enums return as themselves, through a generated
`__nsReportAnyObject` with subclasses checked first. JSON-like payloads
(`[String: Any]`) now work both ways, and TypeScript types Any as
`unknown`.

UUID and Character cross as strings, and an invalid value throws a
TypeError. Decimal (NSDecimal on Apple platforms) crosses as a number.
None of these needs a new runtime value kind.

Thunk ABI 16: the signature code `a`. Against the iOS 18 SDK this adds
64 Foundation, 3 SwiftUI and 2 SwiftData callables. Every module's
thunks still type-check.
The guide now opens with the three steps an app takes: write Swift, list
the module in build.xcconfig, and import it. It ends with recipes for
the patterns apps reach for:
- a SwiftUI view in a NativeScript layout;
- a JS delegate held weakly;
- a cancellable request;
- streams;
- JSON-like payloads;
- Swift-only API on a UIKit subclass.
The matched suite adds a 1 MB Data argument (native checksums it) and a
1 MB Data result (JS materializes the bytes). In a Release run on the
iPhone 17 Pro simulator, the Swift path takes 0.10 of the Objective-C
time for the argument and 0.67 for the result. The docs table is
refreshed from the same run.
In a Release app whose unit is its own module, WeatherKit and a SwiftPM
package, the thunks compile to 0.8 MB and the first ns:swift import
takes 2.7 ms on the simulator.
… JS thread

A closure or protocol call that Swift makes on the isolate's thread inside a
context now converts its arguments through the return* entries into a pooled
DirectCall instead of recording an AsyncOutcome tree, so an invocation no
longer allocates per argument. Invocations from other threads keep the
recorded path. The three callback runners share one invoke, closures cache
their MemberInfo, and object handles cache their requirement keys.

Release, iPhone 17 Pro simulator: a closure calling JS 100 times goes from
1.97x to 0.82x of an Objective-C block; a protocol call on a JS object from
2.59x to 1.08x of a JS subclass.
…sh figures

TNSBenchmark.echoLater now runs on a global queue and completes on the main
queue, as a nonisolated Swift async function runs on the cooperative pool
and settles on the JS thread. The docs table is refreshed from one Release
run.
@NathanWalker
NathanWalker force-pushed the feat/swift-metadata-gen branch from dd47003 to 97b5a0d Compare October 21, 2026 16:44
@NathanWalker
NathanWalker force-pushed the swift-interop/6-lifetime-async branch from d43b891 to 1c568f9 Compare October 21, 2026 16:44
@NathanWalker NathanWalker added this to the 10.0 milestone Oct 21, 2026

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