|
| 1 | +# JavaScript Interop Cheat Sheet |
| 2 | + |
| 3 | +Practical recipes for manipulating JavaScript values from Swift with JavaScriptKit. Each section shows the shortest path to access, call, or convert the APIs you interact with the most. |
| 4 | + |
| 5 | +## Access JavaScript Values |
| 6 | + |
| 7 | +### Global entry points |
| 8 | + |
| 9 | +```swift |
| 10 | +let global: JSObject = JSObject.global |
| 11 | +let document: JSObject = global.document.object! |
| 12 | +let math: JSObject = global.Math.object! |
| 13 | +``` |
| 14 | + |
| 15 | +- Use ``JSObject/global`` for `globalThis` and drill into properties. |
| 16 | +- Accessing through [dynamic member lookup](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0195-dynamic-member-lookup.md) returns ``JSValue``; call `.object`, `.number`, `.string`, etc. to unwrap a concrete type (callable values are represented as ``JSObject`` as well). |
| 17 | +- Prefer storing ``JSObject`` references (`document` above) when you call multiple members to avoid repeated conversions (for performance). |
| 18 | + |
| 19 | +### Properties, subscripts, and symbols |
| 20 | + |
| 21 | +```swift |
| 22 | +extension JSObject { |
| 23 | + public subscript(_ name: String) -> JSValue { get set } |
| 24 | + public subscript(_ index: Int) -> JSValue { get set } |
| 25 | + public subscript(_ name: JSSymbol) -> JSValue { get set } |
| 26 | + /// Use this API when you want to avoid repeated String serialization overhead |
| 27 | + public subscript(_ name: JSString) -> JSValue { get set } |
| 28 | + /// A convenience method of `subscript(_ name: String) -> JSValue` |
| 29 | + /// to access the member through Dynamic Member Lookup. |
| 30 | + /// ```swift |
| 31 | + /// let document: JSObject = JSObject.global.document.object! |
| 32 | + /// ``` |
| 33 | + public subscript(dynamicMember name: String) -> JSValue { get set } |
| 34 | +} |
| 35 | +extension JSValue { |
| 36 | + /// An unsafe convenience method of `JSObject.subscript(_ index: Int) -> JSValue` |
| 37 | + /// - Precondition: `self` must be a JavaScript Object. |
| 38 | + public subscript(dynamicMember name: String) -> JSValue |
| 39 | + public subscript(_ index: Int) -> JSValue |
| 40 | +} |
| 41 | +``` |
| 42 | + |
| 43 | +**Example** |
| 44 | + |
| 45 | +```swift |
| 46 | +document.title = .string("Swift <3 Web") |
| 47 | +let obj = JSObject.global.myObject.object! |
| 48 | +let value = obj["key"].string // Access object property with String |
| 49 | +let propName = JSString("key") |
| 50 | +let value2 = obj[propName].string // Access object property with JSString |
| 51 | + |
| 52 | +let array = JSObject.global.Array.object!.new(1, 2, 3) |
| 53 | +array[0] = .number(10) // Assign to array index |
| 54 | + |
| 55 | +let symbol = JSSymbol("secret") |
| 56 | +let data = obj[symbol].object |
| 57 | +``` |
| 58 | + |
| 59 | +## Call Functions and Methods |
| 60 | + |
| 61 | +```swift |
| 62 | +extension JSObject { |
| 63 | + /// Call this function with given `arguments` using [Callable values of user-defined nominal types](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0253-callable.md) |
| 64 | + /// ```swift |
| 65 | + /// let alert = JSObject.global.alert.object! |
| 66 | + /// alert("Hello from Swift") |
| 67 | + /// ``` |
| 68 | + public func callAsFunction(_ arguments: ConvertibleToJSValue...) -> JSValue |
| 69 | + public func callAsFunction(this: JSObject, _ arguments: ConvertibleToJSValue...) -> JSValue |
| 70 | + |
| 71 | + /// Returns the `name` member method binding this object as `this` context. |
| 72 | + public subscript(_ name: String) -> ((ConvertibleToJSValue...) -> JSValue)? { get } |
| 73 | + public subscript(_ name: JSString) -> ((ConvertibleToJSValue...) -> JSValue)? { get } |
| 74 | + /// A convenience method of `subscript(_ name: String) -> ((ConvertibleToJSValue...) -> JSValue)?` to access the member through Dynamic Member Lookup. |
| 75 | + /// ```swift |
| 76 | + /// let document = JSObject.global.document.object! |
| 77 | + /// let divElement = document.createElement!("div") |
| 78 | + /// ``` |
| 79 | + public subscript(dynamicMember name: String) -> ((ConvertibleToJSValue...) -> JSValue)? { get } |
| 80 | +} |
| 81 | +extension JSValue { |
| 82 | + /// An unsafe convenience method of `JSObject.subscript(_ name: String) -> ((ConvertibleToJSValue...) -> JSValue)?` |
| 83 | + /// - Precondition: `self` must be a JavaScript Object and specified member should be a callable object. |
| 84 | + public subscript(dynamicMember name: String) -> ((ConvertibleToJSValue...) -> JSValue) |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +**Example** |
| 89 | + |
| 90 | +```swift |
| 91 | +let alert = JSObject.global.alert.object! |
| 92 | +alert("Hello from Swift") |
| 93 | + |
| 94 | +let console = JSObject.global.console.object! |
| 95 | +_ = console.log!("Cheat sheet ready", 1, true) |
| 96 | + |
| 97 | +let document = JSObject.global.document.object! |
| 98 | +let button = document.createElement!("button").object! |
| 99 | +_ = button.classList.add("primary") |
| 100 | +``` |
| 101 | + |
| 102 | +- **Dynamic Member Lookup and `!`**: When calling a method on ``JSObject`` (like `createElement!`), it returns an optional closure, so `!` is used to unwrap and call it. In contrast, calling on ``JSValue`` (like `button.classList.add`) returns a non-optional closure that traps on failure for convenience. |
| 103 | + |
| 104 | +Need to bind manually? Grab the function object and supply `this`: |
| 105 | + |
| 106 | +```swift |
| 107 | +let appendChild = document.body.appendChild.object! |
| 108 | +appendChild(this: document.body.object!, document.createElement!("div")) |
| 109 | +``` |
| 110 | + |
| 111 | +### Passing options objects |
| 112 | + |
| 113 | +When JavaScript APIs require an options object, create one using ``JSObject``: |
| 114 | + |
| 115 | +```swift |
| 116 | +public class JSObject: ExpressibleByDictionaryLiteral { |
| 117 | + /// Creates an empty JavaScript object (equivalent to {} or new Object()) |
| 118 | + public init() |
| 119 | + |
| 120 | + /// Creates a new object with the key-value pairs in the dictionary literal |
| 121 | + public init(dictionaryLiteral elements: (String, JSValue)...) |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +**Example** |
| 126 | + |
| 127 | +```swift |
| 128 | +// Create options object with dictionary literal |
| 129 | +let listeningOptions: JSObject = ["once": .boolean(true), "passive": .boolean(true)] |
| 130 | +button.addEventListener!("click", handler, listeningOptions) |
| 131 | + |
| 132 | +// Create empty object and add properties |
| 133 | +let fetchOptions = JSObject() |
| 134 | +fetchOptions["method"] = .string("POST") |
| 135 | +let headers: JSObject = ["Content-Type": .string("application/json")] |
| 136 | +fetchOptions["headers"] = headers.jsValue |
| 137 | +fetchOptions["body"] = "{}".jsValue |
| 138 | + |
| 139 | +let fetch = JSObject.global.fetch.object! |
| 140 | +let response = fetch("https://api.example.com", fetchOptions) |
| 141 | +``` |
| 142 | + |
| 143 | +### Throwing JavaScript |
| 144 | + |
| 145 | +JavaScript exceptions surface as ``JSException``. Wrap the function (or object) in a throwing helper. |
| 146 | + |
| 147 | +```swift |
| 148 | +// Method |
| 149 | +let JSON = JSObject.global.JSON.object! |
| 150 | +do { |
| 151 | + let value = try JSON.throwing.parse!("{\"flag\":true}") |
| 152 | +} catch let error as JSException { |
| 153 | + print("Invalid JSON", error) |
| 154 | +} |
| 155 | + |
| 156 | +// Function |
| 157 | +let validateAge: JSObject = JSObject.global.validateAge.object! |
| 158 | +do { |
| 159 | + try validateAge.throws(-3) |
| 160 | +} catch let error as JSException { |
| 161 | + print("Validation failed:", error) |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +- Use ``JSObject/throwing`` to access object methods that may throw JavaScript exceptions. |
| 166 | +- Use ``JSObject/throws`` to call the callable object itself that may throw JavaScript exceptions. |
| 167 | + |
| 168 | +### Constructors and `new` |
| 169 | + |
| 170 | +```swift |
| 171 | +let url = JSObject.global.URL.object!.new("https://example.com", "https://example.com") |
| 172 | +let searchParams = url.searchParams.object! |
| 173 | +``` |
| 174 | + |
| 175 | +Use ``JSThrowingFunction/new(_:)`` (via `throws.new`) when the constructor can throw. |
| 176 | + |
| 177 | +## Convert Between Swift and JavaScript |
| 178 | + |
| 179 | +### Swift -> JavaScript |
| 180 | + |
| 181 | +Types conforming to ``ConvertibleToJSValue`` can be converted via the `.jsValue` property. Conversion behavior depends on the context: |
| 182 | + |
| 183 | +| Swift type | JavaScript result | Notes | |
| 184 | +|------------|------------------|-------| |
| 185 | +| `Bool` | `JSValue.boolean(Bool)` | | |
| 186 | +| `String` | `JSValue.string(JSString)` | Wrapped in ``JSString`` to avoid extra copies | |
| 187 | +| `Int`, `UInt`, `Int8-32`, `UInt8-32`, `Float`, `Double` | `JSValue.number(Double)` | All numeric types convert to `Double` | |
| 188 | +| `Int64`, `UInt64` | `JSValue.bigInt(JSBigInt)` | Converted to `BigInt` (requires `import JavaScriptBigIntSupport`) | |
| 189 | +| `Data` | `Uint8Array` | Converted to `Uint8Array` (requires `import JavaScriptFoundationCompat`) | |
| 190 | +| `Array<Element>` where `Element: ConvertibleToJSValue` | JavaScript Array | Each element converted via `.jsValue` | |
| 191 | +| `Dictionary<String, Value>` where `Value: ConvertibleToJSValue` | Plain JavaScript object | Keys must be `String` | |
| 192 | +| `Optional.none` | `JSValue.null` | Use ``JSValue/undefined`` when you specifically need `undefined` | |
| 193 | +| `Optional.some(wrapped)` | `wrapped.jsValue` | | |
| 194 | +| ``JSValue``, ``JSObject``, ``JSString`` | Passed through | No conversion needed | |
| 195 | + |
| 196 | +**Function arguments**: Automatic conversion when passing to JavaScript functions: |
| 197 | + |
| 198 | +```swift |
| 199 | +let alert = JSObject.global.alert.object! |
| 200 | +alert("Hello") // String automatically converts via .jsValue |
| 201 | + |
| 202 | +let console = JSObject.global.console.object! |
| 203 | +console.log!("Count", 42, true) // All arguments auto-convert |
| 204 | +``` |
| 205 | + |
| 206 | +**Property assignment**: Explicit conversion required: |
| 207 | + |
| 208 | +```swift |
| 209 | +let obj = JSObject.global.myObject.object! |
| 210 | +let count: Int = 42 |
| 211 | +let message = "Hello" |
| 212 | + |
| 213 | +obj["count"] = count.jsValue |
| 214 | +obj["message"] = message.jsValue |
| 215 | + |
| 216 | +obj.count = count.jsValue |
| 217 | +obj.message = message.jsValue |
| 218 | + |
| 219 | +// Alternative: use JSValue static methods |
| 220 | +obj["count"] = .number(Double(count)) |
| 221 | +obj.message = .string(message) |
| 222 | +divElement.innerText = .string("Count \(count)") |
| 223 | +canvasElement.width = .number(Double(size)) |
| 224 | +``` |
| 225 | + |
| 226 | +### JavaScript -> Swift |
| 227 | + |
| 228 | +Access JavaScript values through ``JSValue`` accessors: |
| 229 | + |
| 230 | +```swift |
| 231 | +let jsValue: JSValue = // ... some JavaScript value |
| 232 | + |
| 233 | +// Primitive types via direct accessors (most common pattern) |
| 234 | +let message: String? = jsValue.string |
| 235 | +let n: Double? = jsValue.number |
| 236 | +let flag: Bool? = jsValue.boolean |
| 237 | +let obj: JSObject? = jsValue.object |
| 238 | + |
| 239 | +// Access nested properties through JSObject subscripts |
| 240 | +if let obj = jsValue.object { |
| 241 | + let nested = obj.key.string |
| 242 | + let arrayItem = obj.items[0].string |
| 243 | + let count = obj.count.number |
| 244 | +} |
| 245 | + |
| 246 | +// Arrays (if elements conform to ConstructibleFromJSValue) |
| 247 | +if let items = [String].construct(from: jsValue) { |
| 248 | + // Use items |
| 249 | +} |
| 250 | + |
| 251 | +// Dictionaries (if values conform to ConstructibleFromJSValue) |
| 252 | +if let data = [String: Int].construct(from: jsValue) { |
| 253 | + // Use data |
| 254 | +} |
| 255 | + |
| 256 | +// For complex Decodable types, use JSValueDecoder |
| 257 | +struct User: Decodable { |
| 258 | + let name: String |
| 259 | + let age: Int |
| 260 | +} |
| 261 | +let user = try JSValueDecoder().decode(User.self, from: jsValue) |
| 262 | +``` |
| 263 | + |
| 264 | +## Pass Swift Closures back to JavaScript |
| 265 | + |
| 266 | +```swift |
| 267 | +public class JSClosure: JSObject, JSClosureProtocol { |
| 268 | + public init(_ body: @escaping (sending [JSValue]) -> JSValue) |
| 269 | + public static func async( |
| 270 | + priority: TaskPriority? = nil, |
| 271 | + _ body: @escaping (sending [JSValue]) async throws(JSException) -> JSValue |
| 272 | + ) -> JSClosure |
| 273 | + public func release() |
| 274 | +} |
| 275 | + |
| 276 | +public class JSOneshotClosure: JSObject, JSClosureProtocol { |
| 277 | + public init(_ body: @escaping (sending [JSValue]) -> JSValue) |
| 278 | + public static func async( |
| 279 | + priority: TaskPriority? = nil, |
| 280 | + _ body: @escaping (sending [JSValue]) async throws(JSException) -> JSValue |
| 281 | + ) -> JSOneshotClosure |
| 282 | + public func release() |
| 283 | +} |
| 284 | +``` |
| 285 | + |
| 286 | +**Example** |
| 287 | + |
| 288 | +```swift |
| 289 | +let document = JSObject.global.document.object! |
| 290 | +let console = JSObject.global.console.object! |
| 291 | + |
| 292 | +// Persistent closure - keep reference while JavaScript can call it |
| 293 | +let button = document.createElement!("button").object! |
| 294 | +let handler = JSClosure { args in |
| 295 | + console.log!("Clicked", args[0]) |
| 296 | + return .undefined |
| 297 | +} |
| 298 | +button.addEventListener!("click", handler) |
| 299 | + |
| 300 | +// One-shot closure - automatically released after first call |
| 301 | +button.addEventListener!( |
| 302 | + "click", |
| 303 | + JSOneshotClosure { _ in |
| 304 | + console.log!("One-off click") |
| 305 | + return .undefined |
| 306 | + }, |
| 307 | + ["once": true] |
| 308 | +) |
| 309 | + |
| 310 | +// Async closure - bridges Swift async to JavaScript Promise |
| 311 | +let asyncHandler = JSClosure.async { _ async throws(JSException) -> JSValue in |
| 312 | + try! await Task.sleep(nanoseconds: 1_000_000) |
| 313 | + console.log!("Async closure finished") |
| 314 | + return .undefined |
| 315 | +} |
| 316 | +button.addEventListener!("async", asyncHandler) |
| 317 | +``` |
| 318 | + |
| 319 | +## Promises and `async/await` |
| 320 | + |
| 321 | +```swift |
| 322 | +public final class JSPromise: JSBridgedClass { |
| 323 | + public init(unsafelyWrapping object: JSObject) |
| 324 | + public init(resolver: @escaping (@escaping (Result) -> Void) -> Void) |
| 325 | + public static func async( |
| 326 | + body: @escaping () async throws(JSException) -> Void |
| 327 | + ) -> JSPromise |
| 328 | + public static func async( |
| 329 | + body: @escaping () async throws(JSException) -> JSValue |
| 330 | + ) -> JSPromise |
| 331 | + |
| 332 | + public enum Result { |
| 333 | + case success(JSValue) |
| 334 | + case failure(JSValue) |
| 335 | + } |
| 336 | + |
| 337 | + // Available when JavaScriptEventLoop is linked |
| 338 | + public var value: JSValue { get async throws(JSException) } |
| 339 | + public var result: Result { get async } |
| 340 | +} |
| 341 | +``` |
| 342 | + |
| 343 | +**Example** |
| 344 | + |
| 345 | +```swift |
| 346 | +import JavaScriptEventLoop |
| 347 | + |
| 348 | +JavaScriptEventLoop.installGlobalExecutor() |
| 349 | + |
| 350 | +let console = JSObject.global.console.object! |
| 351 | +let fetch = JSObject.global.fetch.object! |
| 352 | + |
| 353 | +// Wrap existing JavaScript Promise and await from Swift |
| 354 | +Task { |
| 355 | + do { |
| 356 | + let response = try await JSPromise( |
| 357 | + unsafelyWrapping: fetch("https://example.com").object! |
| 358 | + ).value |
| 359 | + console.log!("Fetched data", response) |
| 360 | + } catch let error as JSException { |
| 361 | + console.error!("Fetch failed", error.thrownValue) |
| 362 | + } |
| 363 | +} |
| 364 | + |
| 365 | +// Expose Swift async work to JavaScript |
| 366 | +let swiftPromise = JSPromise.async { |
| 367 | + try await Task.sleep(nanoseconds: 1_000_000_000) |
| 368 | + return .string("Swift async complete") |
| 369 | +} |
| 370 | +``` |
| 371 | + |
| 372 | +- Wrap existing promise-returning APIs with ``JSPromise/init(unsafelyWrapping:)``. |
| 373 | +- Use `JSPromise.async(body:)` (with `Void` or `JSValue` return type) to expose Swift `async/await` work to JavaScript callers. |
| 374 | +- To await JavaScript `Promise` from Swift, import `JavaScriptEventLoop`, call `JavaScriptEventLoop.installGlobalExecutor()` early, and use the `value` property. |
| 375 | +- The `value` property suspends until the promise resolves or rejects, rethrowing rejections as ``JSException``. |
| 376 | + |
0 commit comments