Wrapping JavaScript Libraries in Swift with JavaScriptCore

Published on Feb 7, 2026

Table of Contents

This guide walks through wrapping JavaScript libraries within Swift packages using JavaScriptCore. The patterns are derived from HighlighterSwift, which wraps the Highlight.js library, but apply to any JavaScript library you want to use from Swift.


Table of Contents

  1. Prerequisites
  2. JavaScript Preparation
  3. Step-by-Step Implementation
  4. Pattern Reference
  5. Error Handling Reference
  6. Testing Guidelines
  7. Concurrency & Thread Safety
  8. Vendoring, Provenance & Updates

1. Prerequisites

Required Knowledge

  • Swift Package Manager (SPM) fundamentals
  • Basic JavaScript understanding (no advanced JS required)
  • Familiarity with NSAttributedString (if processing rich text output)

Required Files

FilePurpose
Package.swiftSPM manifest with resource declarations
YourWrapper.swiftMain Swift wrapper class
Shims.swiftCross-platform type aliases — only if your wrapper produces rich text (NSColor/UIFont). A JSON-bridge wrapper (Section 4.7) needs no shim.
your-library.min.jsThe JavaScript library to wrap

Required Imports

import JavaScriptCore  // For JSContext, JSValue
import Foundation      // For Bundle, String, etc.
 
// Platform-specific (handled via Shims.swift):
#if os(macOS)
import AppKit          // For NSColor, NSFont
#else
import UIKit           // For UIColor, UIFont
#endif

Supported Platforms

The reference implementation supports:

  • macOS 11.0+ (Big Sur)
  • iOS 12.0+
  • tvOS 12.0+
  • visionOS 1.0+

Do not copy these numbers into your own package. They are a property of the JavaScript that implementation happens to bundle. Your floor is set by the language features in your bundle, and a modern build can easily require iOS 16.4+. See Section 2.7 for how to derive it instead of guessing.

watchOS is absent for a reason: Apple does not ship the JavaScriptCore framework there at all. No deployment-target tuning makes this approach work on watchOS — you need a different strategy entirely.

This approach is Apple-platforms-only. import JavaScriptCore does not resolve on Linux or Windows, so a library built this way cannot be added to a server-side Swift target. If someone might try, say so in your README rather than letting them find out at link time.


2. JavaScript Preparation

2.1 File Architecture

Rule: Use a single, self-contained JavaScript file.

The reference implementation uses a single minified file (highlight.min.js). The file must be completely self-contained with no external dependencies, import, or require statements.

Acceptable:

// Single file with all logic bundled
var hljs = (function() { /* ... */ })();

Not Acceptable:

import { something } from './other-file.js';  // Will not work
const lib = require('external-lib');          // Will not work

2.2 Scope & Namespace Requirements

Rule: The JavaScript library MUST expose its API via a global variable.

JavaScriptCore accesses JavaScript objects through the global namespace. The library must attach its public API to a global variable.

Correct Pattern (used by Highlight.js):

// The library assigns itself to a global variable
var hljs = { /* methods and properties */ };
 
// Or using an IIFE that assigns to global
var hljs = (function() {
    return {
        highlight: function(code, options) { /* ... */ },
        listLanguages: function() { /* ... */ }
    };
})();

Incorrect Patterns (will NOT work):

// ES6 modules - NOT SUPPORTED
export function highlight() { /* ... */ }
export default { highlight };
 
// CommonJS modules - NOT SUPPORTED
module.exports = { highlight };
exports.highlight = function() { /* ... */ };

Dependency Note: Some bundled/minified builds expose a global but also expect other globals to be present. For example, an HTML formatter may expect js_beautify and css_beautify to be loaded first. In these cases, load order matters: evaluate dependencies before the dependent file.

2.3 Entry Point Requirements

Rule: There is no special "entry point" function. Swift accesses methods directly on the global object.

After evaluating the JavaScript file, Swift extracts the global object and calls its methods directly:

// Swift accesses the global object
let hljs = context.globalObject.objectForKeyedSubscript("hljs")
 
// Swift calls methods on it
hljs.invokeMethod("highlight", withArguments: [code, options])

What you need: A predictable, documented API on the global object. Know which methods to call and what arguments they expect.

UMD Note: Some UMD builds include require(...) paths for Node.js. In JavaScriptCore, those branches won't work. Ensure the bundle can run via the global namespace (often window/global) and add the global polyfills from section 3.2 if needed.

2.4 Syntax Constraints

Allowed JavaScript Features:

  • ES5 syntax (var, function, prototype)
  • ES6 features supported by JavaScriptCore (let, const, arrow functions, classes, template literals)
  • Immediately Invoked Function Expressions (IIFE)
  • Global variable assignment

Prohibited JavaScript Features:

  • import / export statements (ES6 modules)
  • require() / module.exports (CommonJS)
  • DOM APIs (document, window unless polyfilled)
  • Browser-specific APIs (fetch, XMLHttpRequest, localStorage)
  • Node.js APIs (fs, path, process)

2.5 Preparing Third-Party Libraries

If using a third-party library:

  1. Use bundled/UMD builds: Look for files named library.min.js or library.umd.js
  2. Verify global exposure: Check that the library creates a global variable
  3. Test in isolation: Run the JS file in a JavaScriptCore context to verify it works
  4. Include the license: Place the library's license file alongside the JS file

2.6 Directory Structure

Place JavaScript files in a dedicated Assets directory within your Sources:

Sources/
└── YourModule/
    ├── YourWrapper.swift
    ├── ... other Swift files
    └── Assets/
        ├── your-library.min.js
        ├── LICENCE                // License for the JS library
        └── (any additional resources)

Assets/ must live inside the target directory. SwiftPM resolves the paths in resources: relative to the target's own path, so .copy("Assets/…") for target YourModule means Sources/YourModule/Assets/…. Putting Assets/ next to YourModule/ instead is a hard build error — an invalid-resource failure for the missing file, plus a warning about unhandled files in Sources/Assets.

2.7 Deriving Your Deployment Floor From the Bundle

Your package's minimum OS versions are a property of the JavaScript you ship, not of JavaScriptCore in the abstract. JavaScriptCore's language support tracks Safari's, so a bundle using a recent JS feature silently requires a recent OS.

Scan the bundle before choosing platform minimums:

for f in 'Object.hasOwn' '.at(' '.replaceAll(' 'structuredClone' \
         '(?<=' '(?<!' '??=' '||=' '&&=' \
         '.findLast' '.toSorted(' 'Object.groupBy'; do
    printf '%-18s %s\n' "$f" "$(grep -o -F -- "$f" your-library.min.js | wc -l)"
done

Two things about that command, both of which will silently mislead you:

  • grep -o … | wc -l, not grep -c. grep -c counts matching lines. A minified bundle is one line, so every row reports 0 or 1 and the numbers mean nothing.
  • grep -F. Several of those strings are regex metacharacters. Matched as patterns, (?< is a group construct in some dialects — you get a parse error or a zero instead of a hit. Worth checking what grep actually is on your machine, too: if it is ugrep or ripgrep-in-disguise, the default dialect differs from BSD grep and the counts change under you.

Rough mapping (verify anything you find against MDN or caniuse — these move). The macOS column is the release that shipped with that Safari version; Safari can later outrun the OS, but an app linking JavaScriptCore.framework gets the OS's engine, so read the column as a floor:

FeatureSafari / JSCiOSmacOS
\p{...} regex property escapes11.111.310.13.4
String.replaceAll13.113.410.15.4
??=, ||=, &&=141411
Object.hasOwn, .at(), structuredClone, .findLast()15.415.412.3
RegExp lookbehind (?<=, (?<!16.416.413.3
.toSorted(), .with(), Object.groupBy17.417.414.4

A trap worth knowing about. Lookbehind is the feature that most often forces a modern floor, and how it appears in the bundle changes when it bites you:

  • As a regex literal/(?<=\s)x/ — an unsupported construct is a syntax error at parse time, so the whole script fails to evaluate. Loud, obvious, caught by any smoke test.
  • Built at runtimenew RegExp("(?<=\\s)x") — it only throws when that line first executes. A smoke test that checks "did the global appear?" passes cleanly, and the failure surfaces later, on one specific input, on older devices only.

The second form is common in bundled Markdown and HTML parsers. Grep for the construct itself rather than relying on evaluation succeeding, and make sure your tests exercise a code path that actually builds it.

Finally: SwiftPM accepts point releases as strings, which the .v16-style enum cases cannot express:

platforms: [
    .macOS("13.3"),
    .iOS("16.4"),
    .tvOS("16.4"),
    .visionOS("1.0"),
]

3. Step-by-Step Implementation

Step 1: Configure Package.swift

// swift-tools-version: 5.9
import PackageDescription
 
let package = Package(
    name: "YourPackageName",
    // Derive these from the JavaScript you bundle -- see Section 2.7.
    // Use string form when you need a point release: .iOS("16.4")
    platforms: [
        .macOS(.v11),
        .iOS(.v12),
        .tvOS(.v12),
        .visionOS(.v1)
    ],
    products: [
        .library(
            name: "YourModule",
            targets: ["YourModule"]
        ),
    ],
    targets: [
        .target(
            name: "YourModule",
            dependencies: [],
            resources: [
                // CRITICAL: Use .copy() for JavaScript files
                .copy("Assets/your-library.min.js"),
                // Include the JS library's license file
                .copy("Assets/LICENCE"),
                // Add any other resources (CSS, JSON, etc.)
                .copy("Assets/config.json"),
            ]
        ),
        .testTarget(
            name: "YourModuleTests",
            dependencies: ["YourModule"]
        ),
    ]
)

Key Points:

  • Use .copy() (not .process()) for JavaScript files to preserve them exactly
  • Resources are bundled into Bundle.module at runtime
  • List each resource file explicitly

Step 2: Create Cross-Platform Shims

Create Shims.swift to abstract platform differences:

// Shims.swift
 
#if os(macOS)
import AppKit
public typealias PlatformColor = NSColor
public typealias PlatformFont  = NSFont
#else
import UIKit
public typealias PlatformColor = UIColor
public typealias PlatformFont  = UIFont
#endif
 
public typealias AttributedStringKey = NSAttributedString.Key
 
// OPTIONAL: Only needed if your wrapper works with NSTextStorage (rich text editing).
#if os(macOS)
public typealias TextStorageEditActions = NSTextStorageEditActions
#else
public typealias TextStorageEditActions = NSTextStorage.EditActions
#endif

Purpose: Isolate all #if os() conditionals to this single file. The rest of your codebase uses the type aliases.

Note: The PlatformColor and PlatformFont aliases are universally useful. The TextStorageEditActions alias is specific to rich text processing and can be omitted if your wrapper doesn't integrate with NSTextStorage.

Step 3: Create the Main Wrapper Class

// YourWrapper.swift
 
import JavaScriptCore
import Foundation
 
/// Everything the wrapper can fail with. A single `nil` cannot tell a missing
/// resource apart from a corrupt one, a syntax error, or a missing global --
/// and those have completely different fixes.
public enum WrapperError: Error {
    case resourceMissing(name: String)
    case resourceUnreadable(name: String, underlying: any Error)
    case evaluationFailed(message: String)
    case globalMissing(name: String)
    case methodMissing(global: String, method: String)
    case javaScriptException(message: String)
    case unexpectedReturnValue(description: String)
}
 
/// Records the most recent JavaScript exception seen by a context.
///
/// This is a separate object rather than a property on the wrapper, for a
/// specific reason: the exception handler must be installed *before* the script
/// is evaluated, which is before `self` is fully initialized. A closure cannot
/// capture `self` at that point, so it captures this instead.
final class ExceptionRecorder {
    var message: String?
 
    /// Reads and clears in one step, so a stale exception is never reported
    /// against a later call.
    func take() -> String? {
        defer { message = nil }
        return message
    }
}
 
public final class YourWrapper {
 
    // MARK: - Private Properties
 
    private let context: JSContext
 
    /// The JavaScript global object (e.g., "hljs", "marked", "prism")
    private let jsLibrary: JSValue
 
    private let recorder = ExceptionRecorder()
 
    /// Bundle reference for loading resources
    private let bundle: Bundle
 
    // MARK: - Initialization
 
    /// Throws a `WrapperError` describing exactly what went wrong.
    public init() throws {
        // 1. Resolve the bundle (handles both SPM and framework contexts)
        // NOTE: `Bundle.module` is a static property synthesized by Swift Package Manager.
        // It only exists when your code is compiled as part of a Swift Package.
        // If you copy this code into a standard Xcode project (not a package),
        // `Bundle.module` will be undefined - that's why we use the #if check.
        #if SWIFT_PACKAGE
        let bundle = Bundle.module
        #else
        let bundle = Bundle(for: YourWrapper.self)
        #endif
        self.bundle = bundle
 
        // 2. Locate the JavaScript file
        guard let url = bundle.url(forResource: "your-library.min", withExtension: "js") else {
            throw WrapperError.resourceMissing(name: "your-library.min.js")
        }
 
        // 3. Load JavaScript source code
        let jsSource: String
        do {
            jsSource = try String(contentsOf: url, encoding: .utf8)
        } catch {
            throw WrapperError.resourceUnreadable(
                name: "your-library.min.js",
                underlying: error
            )
        }
 
        // 4. Create the context
        guard let context = JSContext() else {
            throw WrapperError.evaluationFailed(
                message: "JavaScriptCore did not provide a JSContext."
            )
        }
        self.context = context
 
        // 5. Install the exception handler BEFORE evaluating anything.
        //    Install it afterwards and a syntax error in the bundle is
        //    invisible to you.
        let recorder = self.recorder
        context.exceptionHandler = { context, exception in
            recorder.message = exception?.toString() ?? "Unknown JavaScript error"
            // Hygiene, not correctness: drops a stale JSValue (and the object
            // graph it retains) that anyone reading `context.exception` later
            // would otherwise see. See Section 5.5 for what this does *not* do.
            context?.exception = nil
        }
 
        // 6. Evaluate once. Passing the URL gives better source attribution.
        context.evaluateScript(jsSource, withSourceURL: url)
        if let message = recorder.take() {
            throw WrapperError.evaluationFailed(message: message)
        }
 
        // 7. Verify the global exists.
        // IMPORTANT: Replace "yourGlobalName" with your library's actual global variable
        guard let jsLibrary = context.globalObject?.objectForKeyedSubscript("yourGlobalName"),
              !jsLibrary.isUndefined, !jsLibrary.isNull else {
            throw WrapperError.globalMissing(name: "yourGlobalName")
        }
        self.jsLibrary = jsLibrary
 
        // 8. Verify the methods you actually depend on. A global that exists
        //    but is the wrong build is a real failure mode, and it is much
        //    cheaper to catch here than at the first call site.
        //
        //    Note: `JSValue` has no `isFunction`. Check that the property is a
        //    present object -- functions are objects in JavaScript.
        guard let method = jsLibrary.objectForKeyedSubscript("process"),
              !method.isUndefined, !method.isNull, method.isObject else {
            throw WrapperError.methodMissing(global: "yourGlobalName", method: "process")
        }
    }
}

Why not init?()?

A failable initializer collapses five distinct failures into one nil: resource missing, resource unreadable, script syntax error, global missing, method missing. The first means a broken build configuration, the third means a bad vendored artifact, the fourth means you have the wrong global name. They need different fixes, and nil tells the caller none of it. The typed enum above costs about forty lines and turns every one of them into a readable message. Section 5 uses this style throughout.

By default, JavaScript console.log() calls produce no output in Xcode. To capture JavaScript logs during development, inject a console polyfill.

Order matters: add it after the exception handler is installed (Step 3, item 5) and before the library is evaluated (item 6). Inject it earlier and a mistake in your own polyfill JavaScript is silently swallowed — which is exactly the failure the handler exists to prevent.

// MARK: - Debug Logging Setup (add after JSContext creation)
 
// Create a Swift function that JavaScript can call
let consoleLog: @convention(block) (String) -> Void = { message in
    print("[JS Log]: \(message)")
}
 
// Inject the function into the JavaScript global scope
context.setObject(consoleLog, forKeyedSubscript: "swiftLog" as NSString)
 
// Create a console.log polyfill that calls our Swift function
// Note: JavaScript console.log accepts variadic arguments (e.g., console.log("a", "b", obj))
// The JS wrapper collects all arguments, converts objects to JSON, joins them with spaces,
// and passes the final single string to Swift. Swift only sees the combined result.
context.evaluateScript("""
    var console = {
        log: function() {
            var args = Array.prototype.slice.call(arguments);
            swiftLog(args.map(function(arg) {
                return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
            }).join(' '));
        },
        warn: function() {
            console.log.apply(console, ['[WARN]'].concat(Array.prototype.slice.call(arguments)));
        },
        error: function() {
            console.log.apply(console, ['[ERROR]'].concat(Array.prototype.slice.call(arguments)));
        }
    };
    """)

Note the apply/concat in warn and error. The obvious-looking console.log('[WARN]', arguments) passes the arguments object, which log then JSON.stringifys — so you get [WARN] {"0":"Deprecated function called"} instead of the message.

What This Does:

  • Creates a Swift closure that prints to the Xcode console
  • Injects it into the JavaScript context as swiftLog
  • Defines a JavaScript console object that routes log, warn, and error calls to Swift
  • Handles multiple arguments and object serialization

Example Output:

[JS Log]: Processing input: hello world
[JS Log]: [WARN] Deprecated function called
[JS Log]: Result: {"status": "success", "count": 42}

Production Consideration: You may want to conditionally enable this only in DEBUG builds:

#if DEBUG
// ... console polyfill code ...
#endif

Retain Cycle Warning: If you move this logging setup into a method that references self (e.g., to log to a custom logger instance), remember to capture self weakly:

// If your closure references self (e.g., self.logger.log(message)):
let consoleLog: @convention(block) (String) -> Void = { [weak self] message in
    self?.logger.log("[JS]: \(message)")
}

See Section 4.5 for full details on retain cycles with JavaScript callbacks.

Step 3.2: Add Common Polyfills (If Needed)

Many JavaScript libraries (especially UMD builds) assume the existence of browser globals like window or self. If a library fails to load with errors about missing globals, add these polyfills immediately after creating the context:

// Polyfill browser globals that many JS libraries expect
context.evaluateScript("""
    var window = this;
    var self = this;
    var global = this;
    """)

When You Need This:

  • Library throws "window is not defined" or "self is not defined"
  • Library was built for browser/Node.js and uses UMD module format
  • Library checks typeof window !== 'undefined' for environment detection

When You Don't Need This:

  • Library was specifically built for JavaScriptCore
  • Library works correctly without these globals (like Highlight.js)

Add polyfills before evaluating the library script:

guard let context = JSContext() else {
    throw WrapperError.evaluationFailed(message: "JavaScriptCore did not provide a JSContext.")
}
 
// 1. Exception handler first, so failures below are not silent
context.exceptionHandler = { context, exception in
    recorder.message = exception?.toString() ?? "Unknown JavaScript error"
    context?.exception = nil
}
 
// 2. Then polyfills
context.evaluateScript("var window = this; var self = this;")
 
// 3. Then the library
context.evaluateScript(jsSource, withSourceURL: url)

Step 4: Implement Method Wrappers

For each JavaScript method you need to call, create a Swift wrapper:

// Continuing in YourWrapper.swift
 
extension YourWrapper {
 
    // MARK: - The one place that calls JavaScript
 
    /// Invokes a JavaScript method and turns a thrown JS exception into a
    /// Swift error.
    ///
    /// Route every call through a helper like this. See Section 5.5 for why
    /// checking the return value is not enough.
    private func invoke(_ method: String, _ arguments: [Any]) throws -> JSValue {
        _ = recorder.take()  // discard anything stale before calling
 
        let returned = jsLibrary.invokeMethod(method, withArguments: arguments)
 
        // CRITICAL: check the recorder BEFORE inspecting the return value.
        // A method that throws returns `undefined`, not nil.
        if let message = recorder.take() {
            throw WrapperError.javaScriptException(message: message)
        }
        guard let returned else {
            throw WrapperError.javaScriptException(
                message: "\(method) returned no value and reported no exception."
            )
        }
        return returned
    }
 
    // MARK: - Public Methods
 
    /// Example: Wrapping a JavaScript function with one argument
    /// JavaScript: yourLib.process(input) -> { result: string, status: number }
    public func process(_ input: String) throws -> String {
        let result = try invoke("process", [input])
 
        guard let value = result.objectForKeyedSubscript("result"),
              !value.isUndefined, !value.isNull,
              let string = value.toString() else {
            throw WrapperError.unexpectedReturnValue(
                description: "process() did not return an object with a string `result`."
            )
        }
        return string
    }
 
    /// Example: Wrapping a function with options dictionary
    /// JavaScript: yourLib.transform(code, { option1: value, option2: value })
    public func transform(_ code: String, option1: Bool, option2: String) throws -> String {
        let options: [String: Any] = [
            "option1": option1,
            "option2": option2
        ]
 
        let result = try invoke("transform", [code, options])
        guard result.isString, let string = result.toString() else {
            throw WrapperError.unexpectedReturnValue(
                description: "transform() returned \(result) instead of a string."
            )
        }
        return string
    }
 
    /// Example: Wrapping a function that returns an array
    /// JavaScript: yourLib.listItems() -> ["item1", "item2", ...]
    public func listItems() throws -> [String] {
        let result = try invoke("listItems", [])
        guard let array = result.toArray() as? [String] else {
            throw WrapperError.unexpectedReturnValue(
                description: "listItems() did not return an array of strings."
            )
        }
        return array
    }
}

Omitting an optional argument is not the same as passing null.

If a JavaScript function distinguishes "argument omitted" from "argument was null" — which any function using a default parameter or an === undefined check does — then the argument array length matters:

// JS sees arguments.length == 1, `options` is undefined -> defaults apply
try invoke("convert", [input])
 
// JS sees arguments.length == 2, `options` is null -> may be rejected!
try invoke("convert", [input, NSNull()])

Build the array conditionally rather than always passing a placeholder:

let arguments: [Any] = optionsJSON.map { [input, $0] } ?? [input]

Step 5: Implement Resource Loading (Optional)

If your wrapper needs to load additional resources (CSS, JSON, etc.):

extension YourWrapper {
 
    /// Load a resource file from the bundle
    public func loadResource(named name: String, ofType type: String) -> String? {
        guard let path = bundle.path(forResource: name, ofType: type) else {
            return nil
        }
        return try? String(contentsOfFile: path, encoding: .utf8)
    }
 
    /// List all resources of a specific type
    public func availableResources(ofType type: String) -> [String] {
        let paths = bundle.paths(forResourcesOfType: type, inDirectory: nil) as [NSString]
        return paths.map { $0.lastPathComponent.replacingOccurrences(of: ".\(type)", with: "") }
    }
}

Step 6: Verify the Artifact You Vendored

Do not assume a bundle is suitable just because it loads. Two cheap checks pay for themselves, and both belong in your test suite rather than in a README:

// 1. It must be a classic script that installs the global you expect.
//    An IIFE assigned to a top-level `var` is the shape you want.
//    Paste in the first line of YOUR artifact -- the preamble is
//    bundler-specific. esbuild emits `"use strict";` plus an arrow IIFE:
//        "use strict";
//        var YourGlobal = (() => {
//    Rollup and webpack differ. The point is not the exact text; it is that a
//    later `.js` swap cannot silently change the module shape on you.
XCTAssertTrue(source.hasPrefix("\"use strict\";\nvar YourGlobal = (() => {"))
 
// 2. It must not contain anything a bare JSContext cannot resolve.
for forbidden in ["require(\"", "require('", "from \"node:",
                  "import(", "XMLHttpRequest(", "fetch("] {
    XCTAssertFalse(
        source.contains(forbidden),
        "Bundle contains \(forbidden), which cannot work in a bare JSContext."
    )
}

Section 2.4 lists these constraints; this is how you actually enforce them. A bundle that violates one will often still evaluate without error and then fail on one specific input months later.

If you are vendoring a UMD build, drop require( from that list — Section 2.3 explains why a UMD bundle can carry a Node branch it never takes, and this assertion would fail on nearly every one of them. Assert instead that the global-namespace branch is the one taken, which the empty-vm parity run in Section 6.1 demonstrates far better than any string search.


4. Pattern Reference

4.1 Type Conversion: Swift to JavaScript

When passing Swift values to invokeMethod(_:withArguments:):

Swift TypeJavaScript TypeExample
Stringstring"hello" -> "hello"
Intnumber42 -> 42
Doublenumber3.14 -> 3.14
Boolbooleantrue -> true
DateDateDate() -> new Date()
[Any]array[1, "a", true] -> [1, "a", true]
[String: Any]object["key": "value"] -> {key: "value"}
NSNull()nullNSNull() -> null

A bare nil is not in that table on purpose: let args: [Any] = [nil] does not compile, because Any is not ExpressibleByNilLiteral. You reach null either through NSNull() or via an Any? that is .none. Before you do either, read the "omit, don't null" note in Step 4 — passing NSNull() changes arguments.length on the JavaScript side, and for a function with a default parameter that is a different call entirely.

CRITICAL: JSON-Safe Types Only

The Any type in [String: Any] or [Any] must be limited to JSON-compatible primitives:

  • String
  • Int, Double, Float (numbers)
  • Bool
  • [Any] (nested arrays of primitives)
  • [String: Any] (nested dictionaries of primitives)
  • nil / NSNull()

Custom Swift Structs and Classes will NOT work — and the way they fail is worse than you might expect. They are not converted to null. They are bridged as an opaque wrapper object: typeof x === "object", the value is truthy, and Object.keys(x) is empty.

That means every if (x) and x != null guard on the JavaScript side passes, and only the individual property reads come back undefined — so the failure surfaces far away from its cause. Do not go looking for null; you will not find it.

If you need to pass a custom type, convert it to a Dictionary first:

// WRONG - This will fail silently
struct User { let name: String; let age: Int }
let user = User(name: "Alice", age: 30)
jsLibrary.invokeMethod("process", withArguments: [user])  // user becomes null!
 
// CORRECT - Convert to Dictionary
let userDict: [String: Any] = ["name": user.name, "age": user.age]
jsLibrary.invokeMethod("process", withArguments: [userDict])  // Works!
 
// ALTERNATIVE - Use Codable for complex types
let userData = try JSONEncoder().encode(user)
let userJSON = String(data: userData, encoding: .utf8)!
jsLibrary.invokeMethod("processJSON", withArguments: [userJSON])

Example:

// Swift
let options: [String: Any] = [
    "language": "swift",
    "lineNumbers": true,
    "startLine": 1
]
jsLibrary.invokeMethod("process", withArguments: [code, options])
 
// Equivalent JavaScript
yourLib.process(code, { language: "swift", lineNumbers: true, startLine: 1 })

Performance Note for Large Data — with measurements, because the folklore here is wrong.

You will read that for very large strings you should avoid invokeMethod and instead set a global, then evaluateScript, "to reduce bridging overhead":

// The commonly recommended "faster for large data" version.
// It is not faster. See the numbers below.
context.globalObject.setValue(largeString, forProperty: "inputData")
let result = context.evaluateScript("yourLib.process(inputData)")

I benchmarked all three calling conventions against a JavaScript function that only measures the crossing, across three orders of magnitude of payload. Apple silicon, release build, p50 of 20,000 iterations (2,000 at 1 MB):

Variant~1 KB~100 KB~1 MB
invokeMethod(withArguments:)875 ns6 µs44 µs
setValue + evaluateScript1,167 ns8 µs77 µs
Cached JSValue + .call(withArguments:)667 ns6 µs49 µs

Setting a global is never faster. At 1 KB it is ~33% slower than invokeMethod and ~75% slower than a cached function value. At 100 KB the gap is small enough to be within noise. At 1 MB the three are indistinguishable — a megabyte memcpy swamps everything else, and the run-to-run variance exceeds the difference between variants. There is no crossover, at any size, in either direction.

The reason is simple once measured. The StringJSString conversion is the size-dependent cost, and it is identical in both paths — setting the global costs the same as passing the argument. The evaluateScript call is then pure addition. It is not expensive (JavaScriptCore code-caches the source, so re-evaluating the same one-line script is a flat ~0.54 µs regardless of payload size), but it buys you nothing.

Then stop optimizing this. Behind a real workload the entire question is noise: a conversion that takes 5.8 ms spends about 0.3 µs on bridging — under 0.1% of the call. Cache the function as a JSValue at init and use .call(withArguments:), because it is both the fastest and the clearest, and then go measure the JavaScript instead. That is where your time actually goes.

4.2 Type Conversion: JavaScript to Swift

When extracting values from JSValue:

JavaScript TypeSwift ExtractionResult Type
string.toString()String?
number.toInt32()Int32
number.toDouble()Double
boolean.toBool()Bool
Date.toDate()Date?
Array.toArray()[Any]?
Object.toDictionary()[AnyHashable: Any]?
property.objectForKeyedSubscript("key")JSValue?
array element.objectAtIndexedSubscript(0)JSValue?

Example:

// JavaScript returns: { value: "result", count: 42, items: ["a", "b"] }
 
let result = jsLibrary.invokeMethod("getData", withArguments: [])
 
// Extract string property
let value = result?.objectForKeyedSubscript("value")?.toString()  // "result"
 
// Extract number property
let count = result?.objectForKeyedSubscript("count")?.toInt32()   // 42
 
// Extract array property
let items = result?.objectForKeyedSubscript("items")?.toArray() as? [String]  // ["a", "b"]

4.3 Checking for Undefined/Null

JavaScript functions may return undefined or null. Always check:

func safeExtract(_ jsValue: JSValue?) -> String? {
    // Check if JSValue itself is nil
    guard let value = jsValue else {
        return nil
    }
 
    // Check for JavaScript undefined
    if value.isUndefined {
        return nil
    }
 
    // Check for JavaScript null
    if value.isNull {
        return nil
    }
 
    // Check for string "undefined" (some libs return this)
    let str = value.toString()
    if str == "undefined" {
        return nil
    }
 
    return str
}

Treat that last check as a smell, not a pattern.

str == "undefined" also rejects a function that legitimately returned the five-letter string "undefined", and it exists mainly to paper over a missing exception handler. If you record exceptions properly (Section 5.5), you can distinguish "it threw", "it returned undefined", and "it returned the string undefined" — which are three different things. Prefer value.isString over string-comparing the result of toString(), which stringifies everything, including objects and undefined itself.

4.4 Passing Nested Objects

For complex nested structures:

let config: [String: Any] = [
    "theme": [
        "name": "dark",
        "colors": [
            "background": "#000000",
            "foreground": "#ffffff"
        ]
    ],
    "options": [
        "enabled": true,
        "values": [1, 2, 3]
    ]
]
 
jsLibrary.invokeMethod("configure", withArguments: [config])
 
// JavaScript receives:
// {
//   theme: { name: "dark", colors: { background: "#000000", foreground: "#ffffff" } },
//   options: { enabled: true, values: [1, 2, 3] }
// }

4.5 Handling Callbacks (Advanced)

If the JavaScript library uses callbacks, you can pass Swift closures:

// Define a Swift closure
let callback: @convention(block) (String) -> Void = { result in
    print("Callback received: \(result)")
}
 
// Create a JSValue from the closure
let context = JSContext()!
let jsCallback = JSValue(object: callback, in: context)
 
// Pass to JavaScript
jsLibrary.invokeMethod("processAsync", withArguments: [input, jsCallback as Any])

CRITICAL: Retain Cycle Warning

When using @convention(block) closures that capture self, you can easily create memory leaks:

self -> JSContext -> JSValue (closure) -> self  [RETAIN CYCLE]

If the closure references self (e.g., to update UI or call instance methods), and the JSContext is owned by self, the wrapper will never deallocate.

Always capture self weakly in callbacks:

// WRONG - Creates retain cycle if self owns the JSContext
let callback: @convention(block) (String) -> Void = { result in
    self.handleResult(result)  // Strong capture of self
}
 
// CORRECT - Use [weak self] to break the cycle
let callback: @convention(block) (String) -> Void = { [weak self] result in
    guard let self = self else { return }
    self.handleResult(result)
}
 
// ALTERNATIVE - Use [unowned self] if you guarantee self outlives the callback
let callback: @convention(block) (String) -> Void = { [unowned self] result in
    self.handleResult(result)
}

Note: Callbacks add complexity. Prefer synchronous APIs when available.

4.6 Bundle Resolution Pattern

Always use this pattern for cross-context bundle resolution:

#if SWIFT_PACKAGE
let bundle = Bundle.module
#else
let bundle = Bundle(for: YourWrapper.self)
#endif

This handles:

  • Swift Package Manager builds (Bundle.module)
  • Framework/CocoaPods builds (Bundle(for:))

Bundle.module resolves per target, and this will bite you in tests.

SwiftPM synthesizes a separate Bundle.module accessor for every target that has resources. Inside a test target, Bundle.module is the test bundle — even under @testable import YourModule. So a test that tries to locate your JavaScript through Bundle.module looks in the wrong bundle and fails with a confusing "resource missing" error while the library itself works fine.

Expose the library's own bundle through an internal accessor:

// In the library target:
enum PackageResources {
    static var bundle: Bundle { Bundle.module }  // resolves to YourModule's bundle
}
// In the test target:
@testable import YourModule
 
let url = PackageResources.bundle.url(forResource: "your-library.min", withExtension: "js")

Use that accessor everywhere in the library too, so there is exactly one place that knows how resources are found.

Sections 4.1–4.4 walk JSValue trees by hand. That is fine for a method returning a string or a flat array. It stops being fine the moment the library returns anything structured.

toDictionary() gives you [AnyHashable: Any] — nested NSNumber, NSDictionary, NSNull — and every field access becomes an unchecked cast. A discriminated union ({ kind: "text", ... } | { kind: "pause", ... }) is especially miserable to unpack this way, and nothing tells you when the upstream shape changes.

If the library can hand you JSON, take it. Send JSON in, get JSON out, and let Codable do the work:

struct ConversionResult: Codable {
    let text: String
    let diagnostics: [Diagnostic]
}
 
public func convert(_ input: String, options: Options? = nil) throws -> ConversionResult {
    // Encode options to a JSON string. Swift omits nil properties entirely,
    // which matters if the JS side rejects unknown or null keys.
    let optionsJSON = try options.map { options -> String in
        let data = try JSONEncoder().encode(options)
        return String(decoding: data, as: UTF8.self)
    }
 
    // Omit the argument when there are no options -- see the note in Step 4.
    let arguments: [Any] = optionsJSON.map { [input, $0] } ?? [input]
 
    let returned = try invoke("convertJSON", arguments)
    guard returned.isString, let json = returned.toString() else {
        throw WrapperError.unexpectedReturnValue(description: "Expected a JSON string.")
    }
    return try JSONDecoder().decode(ConversionResult.self, from: Data(json.utf8))
}

What this buys you:

  • The JSValue surface shrinks to one string in and one string out. No nested bridging, no unchecked casts, no JSValue escaping the call.
  • Typed models with real errors. A shape change surfaces as a DecodingError naming the exact key path, not a silent nil.
  • Testability. Models round-trip through Codable with no JavaScript involved, so most of your model tests do not need a JSContext at all.
  • Thread safety gets easier. Strings are Sendable; JSValue is not.

If the library does not already expose a JSON entry point, adding a small one to the bundle is usually easier than hand-decoding on the Swift side:

var yourLib = (function () {
    // ... existing library ...
    return {
        convertJSON: function (input, optionsJSON) {
            var options = optionsJSON === undefined ? undefined : JSON.parse(optionsJSON);
            return JSON.stringify(convert(input, options));
        }
    };
})();

Two caveats. Very large payloads pay a serialization cost on both sides — measure before assuming it matters. And JSON cannot carry functions, so any callback-shaped configuration the library accepts is out of reach through this route; document that rather than pretending it is supported.

Dictionary keys: the trap that produces valid JSON of the wrong shape

If your options include a dictionary keyed by anything other than String or Int, Codable will silently encode it as a flat array instead of an object. Verified behaviour:

enum Level: Int, Codable { case one = 1, two }
 
try JSONEncoder().encode(["a": "x"])        // {"a":"x"}     <- object
try JSONEncoder().encode([1: "x"])          // {"1":"x"}     <- object
try JSONEncoder().encode([Level.one: "x"])  // [1,"x"]       <- ARRAY!

String and Int keys get the special treatment; your own key type does not, even when its raw value is Int. The JS side receives a well-formed JSON array where it expected {"1": …}, and the error you get back is about the shape, not about your Swift model.

The fix is CodingKeyRepresentable:

extension Level: CodingKeyRepresentable {
    private struct Key: CodingKey {
        let stringValue: String
        let intValue: Int?
        init(_ level: Level) { stringValue = String(level.rawValue); intValue = level.rawValue }
        init?(stringValue: String) {
            guard let value = Int(stringValue) else { return nil }
            self.stringValue = stringValue; self.intValue = value
        }
        init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue }
    }
 
    var codingKey: any CodingKey { Key(self) }
 
    init?<T: CodingKey>(codingKey: T) {
        guard let value = codingKey.intValue ?? Int(codingKey.stringValue),
              let level = Level(rawValue: value) else { return nil }
        self = level
    }
}
// Now: try JSONEncoder().encode([Level.one: "x"])  // {"1":"x"}

Write a test that asserts the encoded JSON is an object, not merely that it round-trips through Swift — a round-trip test passes happily on the array form.

Modelling discriminated unions

A union tagged by a kind field is the other place JSValue walking falls apart, and Codable handles it cleanly:

enum Fragment: Codable {
    case text(TextFragment)
    case pause(PauseFragment)
 
    private enum Keys: String, CodingKey { case kind }
    private enum Kind: String, Codable { case text, pause }
 
    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        switch try container.decode(Kind.self, forKey: .kind) {
        case .text:  self = .text(try TextFragment(from: decoder))
        case .pause: self = .pause(try PauseFragment(from: decoder))
        }
    }
 
    func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: Keys.self)
        switch self {
        case let .text(value):  try container.encode(Kind.text, forKey: .kind)
                                try value.encode(to: encoder)
        case let .pause(value): try container.encode(Kind.pause, forKey: .kind)
                                try value.encode(to: encoder)
        }
    }
}

An unknown tag throws a DecodingError naming the offending key, which is exactly what you want when upstream adds a case. If upstream's TypeScript types the union as open"a" | "b" | (string & {}) — mirror that with a RawRepresentable struct wrapping String plus static constants, so an unknown value round-trips instead of failing to decode.


5. Error Handling Reference

5.1 Initialization Failures

Use a throwing initializer with a typed error (see Step 3). Each failure mode has a different cause and a different fix, so each gets its own case:

CaseWhat it actually means
resourceMissingResources were not copied into the bundle — a build/packaging problem
resourceUnreadableThe file is there but corrupt or not UTF-8
evaluationFailedThe bundle has a syntax error, or uses syntax this JSC does not support (Section 2.7)
globalMissingWrong global name, or the bundle is a module build rather than a classic script
methodMissingRight global, wrong version of the library

Consumer Usage:

do {
    let wrapper = try YourWrapper()
    // ...
} catch {
    // The error says which of the five it was, and why.
    print("Failed to initialize JavaScript wrapper: \(error)")
}

Add CustomStringConvertible and LocalizedError conformances so those messages are readable when they reach a log or a UI.

5.2 Method Call Failures

Throw rather than returning nil, so the JavaScript exception message survives:

public func process(_ input: String) throws -> String {
    let result = try invoke("process", [input])  // throws on a JS exception
 
    guard let value = result.objectForKeyedSubscript("output"),
          !value.isUndefined, !value.isNull,
          value.isString, let string = value.toString() else {
        throw WrapperError.unexpectedReturnValue(
            description: "process() did not return an object with a string `output`."
        )
    }
    return string
}

The distinction that matters: invoke throwing means JavaScript raised an error and here is its message; unexpectedReturnValue means JavaScript succeeded but returned a shape we do not understand. Collapsing both into nil throws away the more useful half.

5.3 Boolean Return for Operations

For operations where the caller genuinely does not care why something failed, a Bool is acceptable — but derive it from the recorder, not from isUndefined (Section 5.5), or you will report success for a call that threw:

@discardableResult
public func configure(with options: [String: Any]) -> Bool {
    do {
        _ = try invoke("configure", [options])
        return true
    } catch {
        return false
    }
}

Prefer throws and let the caller discard the error with try? if they want to. That way the information exists for whoever does care.

Consumer Usage:

// Check result
if wrapper.configure(with: options) {
    // Success
} else {
    // Failure
}
 
// Or ignore result
wrapper.configure(with: options)

5.4 Defensive Fallbacks

For non-critical operations, provide sensible defaults:

public func getColor(for key: String) -> PlatformColor {
    guard let result = try? invoke("getColor", [key]),
          result.isString,
          let hexString = result.toString(),
          let color = parseHexColor(hexString) else {
        return PlatformColor.gray  // Fallback to neutral color
    }
    return color
}

Note result.isString rather than comparing toString() against "undefined": toString() stringifies everything, so it happily turns undefined, null and objects into strings that then fail to parse further down.

5.5 Exception Handling (Read This One Twice)

JavaScriptCore does not throw Swift errors for JavaScript errors. The exception handler is the only place the message exists — and this is where most wrappers quietly lose information.

The naive version loses every error message:

// DON'T: the message goes to the console and nowhere else
context.exceptionHandler = { context, exception in
    print("JavaScript Error: \(exception?.toString() ?? "Unknown error")")
}

Two separate problems:

  1. Nothing records the exception. The caller gets nil and the actual reason is in a console you cannot read in production.
  2. The handler cannot capture self during init — it must be installed before evaluateScript, when self is not yet fully initialized. There is a second reason too: JSContext retains its exceptionHandler block, so a handler that captures self creates self → context → block → self and the wrapper never deallocates (Section 4.5 covers the same cycle for callbacks). Both problems are solved by the separate ExceptionRecorder object in Step 3.

A myth worth killing: you will read that you must set context.exception = nil or the context is left "in an exception state" that poisons later calls. That is not how the Objective-C API works, and it is worth knowing because it sends people hunting for the wrong bug.

Exceptions are delivered per call, through an out-parameter internally — not through context state. Two things follow, both easy to verify:

  • Once you install a custom handler, JavaScriptCore never writes context.exception at all. Only the default handler does that. A print-only handler leaves the property nil, so there is nothing to clear.
  • Even if your handler sets it and never clears it, the context is fine. Calling another method afterwards returns its correct value, and evaluateScript("2+2") still returns 4.

Clearing it is still worth doing — it drops a stale JSValue and the whole object graph it retains, and it matters inside @convention(block) callbacks, where assigning context.exception is precisely how you throw back into JavaScript. Just do not mistake it for what keeps the context usable. The thing that actually prevents a stale error being reported against the next call is taking and clearing your own recorder — the _ = recorder.take() at the top of invoke.

The detail that causes real bugs:

A JavaScript method that throws does not make invokeMethod return nil. It returns a JSValue holding undefined.

So this extremely common pattern is wrong:

// DON'T: conflates "JS threw a TypeError" with "JS returned undefined"
guard let result = jsLibrary.invokeMethod("process", withArguments: [input]),
      !result.isUndefined else {
    return nil
}

Both cases produce the identical nil, and the exception's message — often the single most useful string in the whole failure, something like TypeError: options.mode must be one of "a", "b", "c" — is discarded.

Check the recorder before you look at the return value:

_ = recorder.take()  // clear anything stale
 
let returned = jsLibrary.invokeMethod("process", withArguments: [input])
 
// FIRST: did JavaScript throw?
if let message = recorder.take() {
    throw WrapperError.javaScriptException(message: message)
}
 
// ONLY THEN: is the returned value the shape we expect?
guard let returned, returned.isString, let string = returned.toString() else {
    throw WrapperError.unexpectedReturnValue(description: "\(String(describing: returned))")
}

On what to put in the message. exception?.toString() gives you the useful part — "TypeError: message". Resist attaching the JS stack property: for a bundled, minified library it is generated-code noise that is not stable across upstream releases, and it tends to leak into user-visible error text.

Testing this is straightforward — deliberately induce a JS exception and assert you get a useful Swift error, not a nil:

func testInvalidInputSurfacesTheJavaScriptMessage() {
    XCTAssertThrowsError(try wrapper.process(deliberatelyInvalidInput)) { error in
        guard case let WrapperError.javaScriptException(message) = error else {
            return XCTFail("Expected .javaScriptException, got \(error)")
        }
        XCTAssertTrue(message.contains("TypeError"), "Unhelpful message: \(message)")
    }
}
 
func testTheContextIsStillUsableAfterAnException() throws {
    XCTAssertThrowsError(try wrapper.process(deliberatelyInvalidInput))
    XCTAssertEqual(try wrapper.process(validInput), expectedOutput)
}

That second test earns its place, but be clear about what it proves. It does not detect a missing context?.exception = nil — remove that line and it still passes. What it catches is a recorder you forgot to clear: if invoke does not call recorder.take() before invoking, the previous call's message is still sitting there and this test throws instead of returning a value. That is a real and easy mistake, and this is the test that finds it.


6. Testing Guidelines

6.1 Test Categories

CategoryPurposeExample
InitializationVerify wrapper loads correctlytestInit()
Artifact integrityBundle is present, unmodified, classic-script shapedtestChecksumMatchesProvenance()
Valid InputVerify correct behaviortestProcessValidInput()
Invalid InputVerify graceful failuretestProcessInvalidLanguage()
Edge CasesBoundary conditionstestEmptyInput(), testLargeInput()
UnicodeNon-ASCII, emoji, combining markstestEmojiRoundTrip()
ErrorsJS exceptions become useful Swift errorstestInvalidOptionsThrow()
Repeated callsNo state leaks between callstestRepeatedCalls()
Resource LoadingVerify bundled resourcestestAvailableThemes()
ConcurrencyDocumented threading guarantee holdstestConcurrentAccess()

Your Swift tests running the real bundled resource through JavaScriptCore are the authoritative integration check. Nothing else proves the artifact you ship actually works on the runtime you ship it to.

Unicode deserves its own tests. JavaScriptCore stores strings as UTF-16, so astral-plane characters cross the bridge as surrogate pairs and are the most likely thing to be corrupted. Test emoji, combining marks, and right-to-left text explicitly. Two things that will trip you up when writing these assertions:

  • Compare by scalar when a variation selector is involved. "🏳️".contains("🏳") is false, because the grapheme cluster includes U+FE0F and the bare flag character does not.
  • Some libraries strip Unicode category Cf (invisible formatting) characters, and U+200D ZERO WIDTH JOINER is one of them — so 👩‍💻 can legitimately come back as 👩 💻. That is upstream behaviour, not a bridging bug. Pin it with a test so you notice if it changes, rather than "fixing" it.

Reference Output Tests (Strongly Recommended): compare your Swift output against the upstream JavaScript library using a Node-based runner. Load the same .js file you vendored — in a bare vm context with no Node globals, which approximates a JSContext closely — run the library's own test fixtures through it, and record the results as a JSON file your Swift tests replay:

// Scripts/generate-parity-fixtures.mjs
import { readFile, writeFile } from "node:fs/promises";
import vm from "node:vm";
import { fixtures } from "../path/to/upstream/fixtures.mjs";
 
const packageRoot = new URL("..", import.meta.url);  // resolve against the script,
const source = await readFile(                        // not the caller's cwd
    new URL("Sources/YourModule/Assets/your-library.min.js", packageRoot), "utf8");
const context = vm.createContext({});  // empty realm: no process, no require, no window
vm.runInContext(source, context, { filename: "your-library.min.js" });
 
const cases = fixtures.map((f) => ({
    ...f,
    expected: JSON.parse(context.yourGlobalName.convertJSON(f.input)),
}));
await writeFile(new URL("Tests/Fixtures/parity.json", packageRoot),
                JSON.stringify({ cases }, null, 2));

This gives you two things at once. The empty vm context is itself a test — if the bundle needs a host global, it fails here rather than mysteriously on device. And replaying those cases through JavaScriptCore holds your wrapper to the same expected outputs as the upstream package, which is exactly what you want after a version bump. If upstream publishes a cross-runtime corpus, reuse it rather than inventing your own (and attribute it).

Have CI regenerate the fixtures and fail on a diff — that catches "someone updated the JS and forgot to refresh the expectations."

One SwiftPM detail this needs. The generator script and any corpus you vendor live under Tests/, where SwiftPM will refuse to build until every file is either declared a resource or excluded. The generated JSON is a resource; the .mjs and any README are development-time inputs:

.testTarget(
    name: "YourModuleTests",
    dependencies: ["YourModule"],
    exclude: [
        "Fixtures/runtime-corpus.mjs",
        "Fixtures/README.md",
    ],
    resources: [
        .copy("Fixtures/parity.json")
    ]
)

And if you vendor someone else's corpus, record where it came from and under what license — the same obligation Section 8.3 describes for the bundle itself.

6.2 Example Test Structure

import XCTest
@testable import YourModule
 
final class YourWrapperTests: XCTestCase {
 
    var wrapper: YourWrapper!
 
    override func setUpWithError() throws {
        // A throwing initializer means a setup failure names its own cause.
        wrapper = try YourWrapper()
    }
 
    override func tearDown() {
        wrapper = nil
    }
 
    // MARK: - Initialization Tests
 
    func testInit() throws {
        XCTAssertNoThrow(try YourWrapper())
    }
 
    // MARK: - Valid Input Tests
 
    func testProcessValidInput() throws {
        let result = try wrapper.process("valid input")
        XCTAssertFalse(result.isEmpty)
    }
 
    // MARK: - Invalid Input Tests
 
    func testProcessInvalidInput() {
        XCTAssertThrowsError(try wrapper.process(invalidInput)) { error in
            // Assert on the *kind* of failure, not merely that one occurred.
            guard case WrapperError.javaScriptException = error else {
                return XCTFail("Expected .javaScriptException, got \(error)")
            }
        }
    }
 
    // MARK: - Edge Cases
 
    func testEmptyInput() throws {
        // Define expected behavior for empty input
        XCTAssertEqual(try wrapper.process(""), "")
    }
 
    // MARK: - State Isolation
 
    /// One context reused across many calls must not accumulate state.
    /// Interleave inputs so a leak shows up as a result that depends on
    /// whatever ran before it.
    func testRepeatedCallsDoNotLeakState() throws {
        let samples = [("a", "A"), ("b", "B"), ("c", "C")]
        for _ in 0..<40 {
            for (input, expected) in samples.shuffled() {
                XCTAssertEqual(try wrapper.process(input), expected)
            }
        }
    }
 
    // MARK: - Resource Tests
 
    func testAvailableResources() {
        let resources = wrapper.availableResources(ofType: "json")
        XCTAssertFalse(resources.isEmpty, "Should have at least one resource")
    }
}

6.3 Type Conversion Tests

Always test type conversions with edge cases:

func testColorParsing() {
    // Standard 6-digit hex
    XCTAssertEqual(wrapper.parseColor("#FF0000"), expectedRed)
 
    // 3-digit shorthand
    XCTAssertEqual(wrapper.parseColor("#F00"), expectedRed)
 
    // 8-digit with alpha
    XCTAssertEqual(wrapper.parseColor("#FF000080"), expectedRedHalfTransparent)
 
    // Invalid input - should return fallback
    XCTAssertEqual(wrapper.parseColor("invalid"), fallbackGray)
    XCTAssertEqual(wrapper.parseColor("#GGGGGG"), fallbackGray)
}

7. Concurrency & Thread Safety

The Problem

A JSContext belongs to a JSVirtualMachine, and Apple's JSVirtualMachine.h states the model plainly:

Thread safety is supported by locking the virtual machine, with concurrent JavaScript execution supported by allocating separate instances of JSVirtualMachine.

So calls into one virtual machine from several threads serialize on a lock; they do not crash. That is a more useful mental model than "it will crash," because it tells you what you actually get: correctness, and no parallelism.

You should still confine each context to a single queue or actor, for three concrete reasons:

  • JSContext and JSValue are not Sendable. Under Swift 6 strict concurrency the compiler will reject them crossing an isolation boundary, and silencing that with @unchecked buys you nothing.
  • A JSValue that escapes keeps its whole context alive, including every object it transitively references.
  • Contention on the VM lock is invisible latency. Threads pile up behind a lock you did not know was there, and adding threads never makes it faster. If you need real parallelism, allocate separate JSVirtualMachine instances — that is the only thing that provides it.

This is a critical consideration because:

  • UI code typically runs on the main thread
  • Background processing (network callbacks, user-initiated tasks) runs on other threads
  • A wrapper shared across threads without a confinement strategy gives you unpredictable latency, Sendable errors, and lifetime surprises

Threading Strategies

You must choose one of the following patterns based on your use case:

Pattern 1: Transient Instances (Safest)

Create a new wrapper instance for each operation. Each instance has its own JSContext, eliminating thread conflicts.

// Each call creates a fresh context - thread-safe by isolation
func processInBackground(_ input: String, completion: @escaping (String?) -> Void) {
    DispatchQueue.global(qos: .userInitiated).async {
        // New instance = new JSContext = no conflicts
        let result = try? YourWrapper().process(input)
 
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

Trade-offs:

ProsCons
Completely thread-safeRe-evaluates the entire bundle on every call
No locking complexityHigher memory usage (multiple contexts)
Simple to reason aboutSlower for high-frequency operations

Measure that first row before choosing this. For a 726 KB bundle, measured on Apple silicon in release: creating a context and evaluating the script costs ~14 ms raw, or ~16 ms through a wrapper that also locates and reads the resource. A conversion on an existing instance costs 0.5 ms for a short document and 6 ms for a 1.2 KB one.

Note the second number is not a constant — conversion scales with the document, so quoting a single "cost per call" is meaningless. The ratio is what matters, and it is worst exactly where it hurts most: for short inputs, setup is 30x the work you came to do. Even for larger inputs you are burning 16 ms and a fresh multi-megabyte JS heap, per call, to recreate something immutable.

Best For: Genuinely infrequent operations, and tests that specifically exercise initialization or state isolation. If you are calling into JavaScript in a loop, this is the wrong pattern.

Use a single wrapper instance but serialize all access through a dedicated DispatchQueue.

public final class ThreadSafeWrapper {
    private let queue = DispatchQueue(label: "com.yourapp.jswrapper", qos: .userInitiated)
 
    /// Confined to `queue`. Never escapes it, and neither does any `JSValue`.
    private let wrapper: YourWrapper
 
    public init() throws {
        // Build the context ON the queue you will use it from: it costs
        // nothing and removes any question about which thread owns what.
        // Caveat, from JSVirtualMachine.h: a VM runs deferred tasks (GC,
        // WebAssembly compilation) on the run loop of the thread it was
        // initialized on, and that cannot be changed afterwards. A GCD queue
        // has no run loop of its own, which is fine for synchronous,
        // non-WebAssembly work like this -- but if your bundle uses WASM,
        // initialize on a thread with a live run loop instead.
        let queue = self.queue
        self.wrapper = try queue.sync { try YourWrapper() }
    }
 
    /// Synchronous processing (blocks until complete)
    public func processSync(_ input: String) throws -> String {
        try queue.sync {
            try wrapper.process(input)
        }
    }
 
    /// Asynchronous processing (returns immediately)
    ///
    /// Captures `self` rather than `wrapper`: `YourWrapper` is not `Sendable`,
    /// and capturing it in an escaping `@Sendable` closure is an error under
    /// Swift 6 strict concurrency. Capturing `self` is fine, because `self` is
    /// `@unchecked Sendable` below.
    ///
    /// Note it calls `wrapper.process` directly and NOT `processSync` — we are
    /// already on `queue`, and `queue.sync` from within its own serial queue
    /// deadlocks.
    public func processAsync(_ input: String, completion: @escaping (Result<String, Error>) -> Void) {
        queue.async {
            let result = Result { try self.wrapper.process(input) }
            DispatchQueue.main.async {
                completion(result)
            }
        }
    }
}
 
// Safe because: the only mutable state is `wrapper`, which is reachable
// exclusively from inside `queue` -- a private serial queue nothing else can
// submit to -- and no JSValue ever crosses that boundary.
extension ThreadSafeWrapper: @unchecked Sendable {}

Trade-offs:

ProsCons
Single context initializationAll JS calls are serialized (no parallelism)
Lower memory usageSync calls block the calling thread
Predictable performanceSlightly more complex API

Best For: Most applications, especially those with frequent JS calls.

Pattern 3: Actor-Based Isolation (Swift 5.5+)

For modern Swift codebases, use an Actor to provide compile-time thread safety:

@available(macOS 10.15, iOS 13.0, *)
public actor JSWrapperActor {
    private let wrapper: YourWrapper
 
    public init() throws {
        self.wrapper = try YourWrapper()
    }
 
    // Returns String, not JSValue: the conversion happens on the actor.
    public func process(_ input: String) throws -> String {
        try wrapper.process(input)
    }
 
    public func listItems() throws -> [String] {
        try wrapper.listItems()
    }
}
 
// Usage
Task {
    let actor = try JSWrapperActor()
    let result = try await actor.process("hello")
}

WARNING: JSValue is not Sendable. Never let one escape your confinement boundary.

A JSValue belongs to a JSContext, which belongs to a JSVirtualMachine. Once a JSValue escapes the actor (or the serial queue) that owns its context, you lose the guarantee that Swift's concurrency checking is describing your actual access pattern, and the value silently keeps its entire context alive.

Do not:

  • Return a JSValue from an actor method
  • Store a JSValue and use it from elsewhere
  • Call .toString(), .toArray() or anything else on a JSValue outside the confinement that owns it

Convert to native Swift types inside the boundary and return only those.

On the precise reason — it is worth being accurate here, because the imprecise version contradicts Pattern 2 above, which deliberately builds a context in one place and calls it from a queue. A JSValue is bound to its context and virtual machine, not to the thread that happened to create it. Apple's guidance is that a JSVirtualMachine serializes access internally, and that executing JavaScript concurrently requires separate JSVirtualMachine instances — which is also why "use more threads" never makes a single context faster.

The practical rule is unchanged and still the important part: pick one confinement per context, keep every JSValue inside it, and hand out only Sendable values. Build the context on the same queue you will call it from and the question stops arising at all.

// WRONG - hands a JSValue out of the actor
public func rawResult(_ input: String) throws -> JSValue {
    try wrapper.invokeRaw("process", [input])
}
// Caller: let value = try await actor.rawResult("x")
// value.toString()  // Compiles only by defeating Sendable checking.
//                   // Blocks on the VM lock, and keeps the whole context alive.
 
// CORRECT - convert inside the actor, return a Sendable value
public func result(_ input: String) throws -> String {
    try wrapper.process(input)          // returns String
}
 
public func items() throws -> [String] {
    try wrapper.listItems()             // returns [String]
}

Rule: All JSValue -> Swift conversions happen inside the actor. Return only Sendable values (String, Int, Bool, [String], your own Codable models). Note [String: Any] is not Sendable — decode into a typed model instead (Section 4.7).

Trade-offs:

ProsCons
Compile-time safetyRequires Swift 5.5+ / iOS 13+
Clean async/await APIAll access becomes async
No manual lockingLearning curve for actors

Best For: Modern async/await codebases, new projects targeting iOS 13+.

Pattern 4: Lock-Based Protection (Low-Level)

Use NSLock for explicit mutual exclusion:

public final class LockedWrapper {
    private let wrapper: YourWrapper
    private let lock = NSLock()
 
    public init() throws {
        self.wrapper = try YourWrapper()
    }
 
    public func process(_ input: String) throws -> String {
        lock.lock()
        defer { lock.unlock() }
        return try wrapper.process(input)
    }
}

Note the defer placement: it must come before anything that can throw, or an error path leaves the lock held and the next caller deadlocks.

Trade-offs:

ProsCons
Fine-grained controlRisk of deadlocks if misused
Works on all OS versionsManual lock management
Minimal overheadEasy to forget unlock on error paths

Best For: Performance-critical code, legacy codebases, when DispatchQueue overhead matters.

Choosing a Strategy

ScenarioRecommended Pattern
Simple CLI tool, scriptsPattern 1 (Transient) — but reuse one instance if you call in a loop
iOS/macOS app with occasional JS callsPattern 2 (Serial Queue)
High-frequency calls, modern codebasePattern 3 (Actor)
Performance-critical, legacy codePattern 4 (Lock)
Unit testsOne instance per test class via setUpWithError; a fresh instance only where you are testing initialization or state isolation
Genuine parallelism requiredSeparate instances — each gets its own JSVirtualMachine. Nothing else provides it.

Testing Thread Safety

Add a stress test to verify your threading strategy — but assert correctness, not just non-nil. A test that only checks "we got something back" passes even if concurrency scrambles every result, which is the failure you are actually worried about.

Compute the expected answers serially first, then check them under load:

func testConcurrentAccessProducesCorrectResults() throws {
    let wrapper = try ThreadSafeWrapper()
    let inputs = (0..<64).map { "input-\($0)" }
 
    // Ground truth, computed with no concurrency involved.
    let expected = try inputs.map { try wrapper.processSync($0) }
 
    let results = NSMutableArray()
    let lock = NSLock()
 
    DispatchQueue.concurrentPerform(iterations: inputs.count) { index in
        guard let output = try? wrapper.processSync(inputs[index]) else {
            return XCTFail("Conversion \(index) failed")
        }
        lock.lock()
        results.add([index, output] as [Any])
        lock.unlock()
    }
 
    XCTAssertEqual(results.count, inputs.count)
    for entry in results {
        guard let pair = entry as? [Any],
              let index = pair.first as? Int,
              let output = pair.last as? String else {
            return XCTFail("Malformed result")
        }
        // The point of the test: input i still maps to output i.
        XCTAssertEqual(output, expected[index])
    }
}

DispatchQueue.concurrentPerform is simpler than expectations here — it blocks until every iteration finishes, so there is no timeout to tune.

If your public type is Sendable, test it from structured concurrency too, since that exercises a different scheduling path:

func testConcurrentUseFromSwiftConcurrency() async throws {
    let wrapper = try ThreadSafeWrapper()
    let expected = try wrapper.processSync("shared")
 
    try await withThrowingTaskGroup(of: String.self) { group in
        for _ in 0..<32 {
            group.addTask { try wrapper.processSync("shared") }
        }
        for try await output in group {
            XCTAssertEqual(output, expected)
        }
    }
}

A note on Sendable. If you wrap a JSContext in a type you mark @unchecked Sendable, you owe the reader a concrete justification, in a comment, of why it is actually safe. "All mutable state is reachable only from inside a private serial queue that nothing else can submit to, and no JSValue ever crosses that boundary" is a justification. Silence is not — and @unchecked Sendable on a type that merely usually gets called from one thread is how these crashes ship.


8. Vendoring, Provenance & Updates

Everything above treats the JavaScript file as a given. In practice you are redistributing someone else's code inside your package, and that carries obligations the rest of this guide does not cover.

8.1 Record What You Vendored

Six months from now, "which version of the library is this?" must have an answer that is not "check the git log." Record it in a machine-readable file next to the artifact:

{
  "npmPackage": "your-library",
  "version": "0.2.0",
  "repository": "https://github.com/owner/your-library",
  "tarball": "your-library-0.2.0.tgz",
  "javaScriptPathInPackage": "dist/your-library.min.js",
  "javaScriptSHA256": "9f3fedfaa130a9f9...",
  "license": "MIT"
}

Expose the version through your public API — YourWrapper.upstreamVersion — so an app can log it in a crash report or an about screen.

Do not type the version into several files by hand. Generate the Swift constant and the JSON from one place, in one script run. A README that says 0.2.0 while the bundle is 0.3.1 is worse than no README.

8.2 Write an Update Script

Manual upgrade steps rot. A script that takes either an explicit version or latest should:

  1. resolve the requested version against the registry;
  2. download and extract the published tarball;
  3. verify the expected artifacts exist, and fail loudly if not;
  4. copy the JavaScript and every license file into your resources;
  5. rewrite the provenance JSON, the generated Swift constants, and the README;
  6. recompute the SHA-256;
  7. clean up temporary files.

Step 3 is the one people skip, and it is the one that saves you. A version that does not ship the build you need should stop the script cold — with the resources left untouched — rather than silently vendoring the wrong file:

for path in "${EXPECTED_FILES[@]}"; do
    [[ -f "${work_dir}/${path}" ]] || die \
        "${PACKAGE}@${version} does not contain '${path}'. Refusing to vendor an incomplete distribution."
done

Add a --verify mode that re-hashes the checked-in file against the recorded SHA-256 and asserts every license file is present. It needs no network and no package manager, so it runs in CI on every commit and catches both accidental edits and a half-finished upgrade.

8.3 Licenses Are Not Optional

Bundling someone's JavaScript into your package means shipping their license. Two files, not one:

  • The upstream project's own LICENSE.
  • The third-party license file for the bundle — a bundled artifact usually contains a dozen transitive dependencies, each with its own terms. Good build tooling emits a combined THIRD_PARTY_LICENSES.txt; if upstream ships one, vendor it.

Put both in the resource bundle and expose them at runtime, so an app embedding your package can surface attribution without vendoring the files a second time:

public static func licenseText() throws -> String
public static func thirdPartyLicenseText() throws -> String

Then say plainly in your README that anyone redistributing an app built on your package must include them.

8.4 A Minimal CI Setup

Three jobs cover the ground that matters, and none of them need to be clever:

  • Build and test on macOS. The Swift tests running the real resource through JavaScriptCore are the integration check.
  • Verify the artifact offline. Run --verify from a clean checkout. This catches a missing resource or a tampered bundle without any network access.
  • Regenerate parity fixtures and fail on a diff (Section 6.1). This catches an upgraded bundle whose expectations were never refreshed.

Appendix A: Complete Minimal Example

Here is a complete minimal implementation for reference:

// Package.swift
// swift-tools-version: 5.9
import PackageDescription
 
let package = Package(
    name: "MyJSWrapper",
    platforms: [.macOS(.v11), .iOS(.v12)],
    products: [
        .library(name: "MyJSWrapper", targets: ["MyJSWrapper"]),
    ],
    targets: [
        .target(
            name: "MyJSWrapper",
            resources: [.copy("Assets/mylib.min.js")]
        ),
    ]
)
// Sources/MyJSWrapper/MyJSWrapper.swift
import JavaScriptCore
import Foundation
 
public enum MyJSWrapperError: Error {
    case resourceMissing
    case resourceUnreadable(any Error)
    case contextUnavailable
    case evaluationFailed(String)
    case globalMissing
    case javaScriptException(String)
    case unexpectedReturnValue
}
 
final class ExceptionRecorder {
    var message: String?
    func take() -> String? { defer { message = nil }; return message }
}
 
public final class MyJSWrapper {
    private let context: JSContext
    private let jsLib: JSValue
    private let recorder = ExceptionRecorder()
 
    public init() throws {
        #if SWIFT_PACKAGE
        let bundle = Bundle.module
        #else
        let bundle = Bundle(for: MyJSWrapper.self)
        #endif
 
        guard let url = bundle.url(forResource: "mylib.min", withExtension: "js") else {
            throw MyJSWrapperError.resourceMissing
        }
        let source: String
        do {
            source = try String(contentsOf: url, encoding: .utf8)
        } catch {
            throw MyJSWrapperError.resourceUnreadable(error)
        }
        guard let context = JSContext() else {
            throw MyJSWrapperError.contextUnavailable
        }
        self.context = context
 
        // Handler first, so a syntax error in the bundle is not silent.
        let recorder = self.recorder
        context.exceptionHandler = { context, exception in
            recorder.message = exception?.toString() ?? "Unknown JavaScript error"
            context?.exception = nil
        }
 
        context.evaluateScript(source, withSourceURL: url)
        if let message = recorder.take() {
            throw MyJSWrapperError.evaluationFailed(message)
        }
 
        guard let lib = context.globalObject?.objectForKeyedSubscript("myLib"),
              !lib.isUndefined, !lib.isNull else {
            throw MyJSWrapperError.globalMissing
        }
        self.jsLib = lib
    }
 
    public func process(_ input: String) throws -> String {
        _ = recorder.take()
 
        let returned = jsLib.invokeMethod("process", withArguments: [input])
 
        // Exceptions first: a throwing method returns `undefined`, not nil.
        if let message = recorder.take() {
            throw MyJSWrapperError.javaScriptException(message)
        }
        guard let returned, returned.isString, let string = returned.toString() else {
            throw MyJSWrapperError.unexpectedReturnValue
        }
        return string
    }
}
// Sources/MyJSWrapper/Assets/mylib.min.js
var myLib = {
    process: function(input) {
        return "Processed: " + input;
    }
};

Appendix B: Checklist

Before releasing your wrapper:

JavaScript Setup

  • JavaScript file is self-contained (no imports/requires)
  • JavaScript library exposes a global variable
  • Package.swift uses .copy() for JS files
  • Third-party library license is included
  • Bundle scanned for modern JS features; platform minimums derived from it, not copied (Section 2.7)

Swift Implementation

  • Any #if os() conditionals are isolated in Shims.swift
  • Throwing initializer with a typed error distinguishes every failure mode
  • Both the global and the methods you depend on are verified at init
  • All public methods handle undefined/null returns
  • Bundle resolution works for both SPM and framework builds
  • Library's bundle is reachable from the test target (Section 4.6)
  • Only JSON-safe types passed to JavaScript (no custom structs/classes)
  • Optional arguments are omitted, not passed as NSNull()
  • No JSValue appears in the public API

Thread Safety

  • Threading strategy documented and implemented (see Section 7)
  • Wrapper is either transient, queue-protected, or actor-isolated
  • Context is created on the same queue/actor that uses it
  • Any @unchecked Sendable carries a written justification
  • Concurrent access stress test passes

Debugging

  • console.log polyfill added for development builds
  • Exception handler installed before the first evaluateScript
  • Exception handler records the message rather than only printing it
  • Exception handler clears context.exception (hygiene — it is not what keeps the context usable)
  • invoke clears the recorder before each call
  • Call sites check the recorded exception before inspecting the return value

Vendoring

  • Exact upstream version and SHA-256 recorded in provenance
  • Upstream version exposed through the public API
  • Update script exists and fails loudly on missing artifacts
  • Offline --verify mode runs in CI
  • Both the upstream license and the third-party license file are shipped

Testing

  • Tests cover initialization, valid input, invalid input, and edge cases
  • Type conversion tests include edge cases (empty strings, special characters)
  • Unicode tests cover emoji, combining marks, and non-Latin scripts
  • A deliberately induced JS exception produces a useful Swift error
  • The context is still usable after an exception
  • Repeated calls on one instance do not leak state
  • Thread safety stress test asserts correctness, not just non-nil
  • Output compared against the upstream library via a Node runner