Repository navigation
RFC: Swift interop — calling Swift-only APIs from JavaScript through generated thunks (ns:swift)
#57
NathanWalker
started this conversation in
1. Ideas & Discussions
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
RFC: Swift interop — calling Swift-only APIs from JavaScript through generated thunks (
ns:swift)Any, performance, docsdocs/swift-interop.mdon the top branchSummary
NativeScript reaches iOS through Objective-C metadata. That covers UIKit and most of the SDK, but Apple's newer frameworks and nearly every modern Swift package are Swift-only: WeatherKit, SwiftUI, SwiftData, Observation, Swift structs and enums,
asyncfunctions, generics. Today the only way to use them is to hand-write an@objcbridge for each API. This RFC proposes making them callable directly:ns:swift, loads the thunks at runtime.require("ns:swift/WeatherKit")orimport { WeatherService } from "ns:swift/WeatherKit"gives JS classes that mirror the Swift types.asyncbecomes a Promise (cancellable withAbortSignal), closures take JS functions, protocols take plain JS objects,AsyncSequencebecomes an async iterator, and enums with payloads become tagged unions TypeScript can narrow.NS_SWIFT_MODULES, and the existing link step does the rest. No CLI change is needed, and Objective-C interop is untouched.Motivation
Swift-only APIs are invisible. NativeScript's metadata is generated from Objective-C headers. A Swift declaration reaches it only if it is
@objc, which rules out structs, enums with associated values, generics,asyncfunctions (except as completion handlers), protocols with Swift-only types, and every API Apple ships Swift-first. WeatherKit has no Objective-C API at all. SwiftUI and SwiftData can't be expressed in Objective-C. Neither can most of the Swift Package ecosystem.The workaround is a hand-written bridge per API. Every Swift API an app wants needs an
@objcwrapper that flattens Swift types into Objective-C ones:That's boilerplate, and it loses types: JS sees an
NSDictionary, TypeScript seesany, and an enum argument becomes a magic string. It also has to be kept in sync with the Swift API by hand. Plugin authors write these bridges over and over, and most app developers can't write them at all.Apple's direction is Swift-first. New frameworks ship Swift-only, and existing ones gain Swift-only surface (
asyncoverloads,Observation, result builders). Without Swift interop, the share of the platform NativeScript can reach shrinks every year.Guide-level explanation
1. Write Swift, name the module, call it
Put Swift in
App_Resources/iOS/src, add a SwiftPM package (ios.SPMPackages), or name an SDK module:ns build iosgenerates, verifies, compiles and links the thunks. Both import forms survive bundling, because@nativescript/vitealready leavesns:specifiers for the runtime to resolve.2. Values
Bool,Double,Float,CGFloatboolean,numberInt,Int8…UInt64number(orBigIntin); outside ±(2⁵³−1) throwsRangeErrorrather than lose precisionString,Character,UUID,Decimalstring(Decimalasnumber)T?TornullUnits.celsius === "celsius"){ case: "area", width: 3, height: 4 }, a TypeScript union thatswitch (m.case)narrows[T],[String: T],Set<T>, tuplesSet, and array or labeled object copiesDate,URL,DataDate,URL,Uint8ArrayAny,[String: Any]unknownin TypeScript)Result<S, F>{ case: "success", value }or{ case: "failure", value }any ErrorErrornamedSwiftError, withswiftTypeCGRect,CGPoint,CLLocationCoordinate2D, …3. Calls
TypeErrornaming the candidates, and every overload is also reachable by its Swift signature:square["scale(by: Int)"](2).newand also accept one object naming the labels:new Point({ x: 2, y: 3 }).a.plus(b),a.lessThan(b),v.negated().EquatableandComparabletypes getequals()andcompare(), solist.sort((a, b) => a.compare(b))sorts by Swift's<.shelf.subscript("apples")andshelf.setSubscript("apples", 3).inoutparameters take a reference object:bump(ref)withref = { value: 41 }leavesref.value === 42.Polygon.Style.dashed.4. Async, actors, cancellation
An
asynccall runs in a SwiftTaskand settles on the calling isolate's thread, the main thread or a Worker.@MainActormembers run synchronously on the main thread. Called from a Worker, they throw instead of trapping.5. Closures and protocols: JS implementing Swift
removeAll { $0 === listener }matches).weak var delegateholds it only as long as JS does, then becomesnil.Voidclosure is queued to the JS thread.6. Streams, Observation and SwiftUI
7. Generics
Forecast<DayWeather>). All instantiations of a type share one JS class, soinstanceofholds for each, and a publictypealiasadds one no API spells.largest([3, 9])andlargest(["b", "a"])are both declared. WeatherKit'sweather(for:including:)gets eight overloads, one perWeatherQueryinstantiation.some Sequence<Int>,C: Collection where C.Element == X,any Sequence<E>) take JS arrays.8. Swift-only members of Objective-C-visible classes
A Swift
UIVieworNSObjectsubclass stays NativeScript's own Objective-C class (CounterView.new()). Its Swift-only members (methods, Swift-typed properties, Swift-only initializers) are added to that class whenns:swift/<Module>loads. They never replace a member NativeScript already provides.9. Errors
throwErrornamedSwiftError, withmessageandswiftTypeTypeErrorRangeErrorTypeErrorlisting the overloads' keysErrornaming the required version (TypeScript's@remarkssays so too)TypeErrornaming what's missingrequire("ns:swift/Nope")Reference-level details
Pipeline.
Why thunks. Swift's ABI is stable on Apple platforms, but calling it dynamically means reimplementing resilience, value witness tables, generic metadata,
asyncframes and ownership conventions inside the runtime. A thunk is ordinary Swift that the compiler type-checks and optimizes. The runtime only ever sees a small C ABI (NativeScript/runtime/SwiftInterop.h): argument slots in, typed result entries out, an API table for callbacks into the runtime, and completion entries forasyncresults and closures.Classification and verification. Every declaration gets a tier:
objc-direct,swift-generated,swift-specialized,type-only,unsupportedornot-applicable. Each tier comes with reasons, and--swift-coverage-outputwrites them as a report. Symbol graphs don't record every fact a call depends on (~Copyableis often invisible), so--swift-verifycompiles everything and demotes whatswiftcrejects, repeating until the remainder compiles. Against the iOS 27.1 SDK, every generated thunk file type-checks:Generation units. Modules generated together form one unit, so a declaration in one module can take another module's types, and extensions apply across modules. An app generates everything it uses as one unit.
SwiftUICoredeclarations joinSwiftUI.App builds. With
NS_SWIFT_MODULESset, the template'snsld.shruns the generator for each architecture next to the Objective-C metadata step, compiles the thunks and links them (-uper register symbol). The object is cached while the symbol graphs, the generator and the settings stay the same. In a Release build, WeatherKit generation takes about 19 s per architecture the first time. The thunk object for an app module plus WeatherKit (424 callables) plus a SwiftPM package is 0.8 MB, and the firstns:swiftimport takes 2.7 ms.Identity and lifetime. A Swift object is retained once by its JS wrapper and released when the wrapper is collected. Returning the same object again yields the same wrapper. Wrappers are isolate-scoped, so each Worker has its own.
Callbacks. A callback that Swift makes on the JS thread converts its arguments straight to JS values through a pooled frame, with no per-argument allocation. Callbacks from other threads are recorded and run on the JS thread.
Performance. One API is implemented twice, in Objective-C (the metadata path) and in Swift (
ns:swift), and every result is consumed in JS. Release build, iPhone 17 Pro simulator, Swift time ÷ Objective-C time:Datain / outThe gains come mostly from collections and strings: Swift values cross as eager copies of plain JS values, while the Objective-C path wraps
NSArray/NSDictionarylazily and pays per element read.ABI. Thunks are stamped with
NS_SWIFT_ABI_VERSION(16, unreleased), and the runtime refuses a module generated for another version.Workers.
ns:swiftworks in Workers. Each isolate builds its own classes, async results settle on the calling isolate, and@MainActorsynchronous members throw off the main thread.TypeScript
The generator writes one ambient module per Swift module:
The
ns:swiftmodule itself (types/ns-swift.d.ts) types the helpers:NS_TYPESCRIPT_DECLARATIONS_PATH/swift, next to the Objective-C ones.tsc, including lines that must fail (@ts-expect-error).Drawbacks
[T]read in small pieces would be better served by a Swift-side API.asyncprotocol requirements,asyncclosures, and the Swift overlay members of SDK Objective-C classes are left out. Each declaration that's left out appears in the coverage report with its reason, and is absent from the JS surface and its TypeScript declarations, so nothing fails at runtime.Alternatives considered
@objcbridges. They lose types, have to be kept in sync by hand, and most app developers can't write them.@objcwrapper classes automatically. This runs into the same limits as hand-written bridges: no structs, no payload enums, no generics,asynconly as completion handlers. Every call also pays forobjc_msgSend, boxing and lazy collection wrappers. The benchmarks show direct thunks beating the Objective-C path on most workloads.asyncand ownership, all of which the compiler already handles in a thunk. It would also lose compile-time verification of the surface.Mirror,NSClassFromStringon Swift classes). Read-only, partial, and not available for structs or functions.nsld.sh) means no CLI release is needed, and it reuses the per-architecture structure the Objective-C metadata step already has. Anativescript.config.tskey could still feedNS_SWIFT_MODULESlater (see the questions below).Android
Android has a parallel gap. Kotlin compiles to JVM bytecode, so most of it is reachable through NativeScript's Java metadata, but in awkward shapes:
suspendfunctions need a hand-builtContinuation;Flowneeds a collector;The same approach could carry over: generated Kotlin thunks plus typed declarations behind an
ns:kotlin/<module>builtin, mappingsuspendto a Promise,Flowto an async iterator and sealed classes to tagged unions. That would give apps one mental model on both platforms. It's out of scope for this RFC's implementation and listed as an open question.Adoption plan
ns:swiftand generatedswift!*.d.tsshould resolve in apptsconfigs by default, and@nativescript/types-iosshould carrytypes/ns-swift.d.ts.NS_SWIFT_MODULES.Unresolved questions
ns:swift/<Module>right? Should the app's own module have an alias (ns:swift/app) so code doesn't depend on the product module name?NS_SWIFT_MODULESinbuild.xcconfig, or shouldnativescript.config.tsgain anios.swiftModuleskey that the CLI forwards?NS_SWIFT_ABI_VERSIONmatch. Should it accept a range, and should the build fail early when the runtime and generator disagree?@nativescript/types-ioswhile thunks stay per app?ns:kotlinwith the same mapping be pursued?All reactions