Swift 6.2 Java interoperability in practice

If you’ve ever tried straddling two worlds—one foot in the elegant, type-safe realm of Swift and the other in the sprawling ecosystem of Java—you know the pain of keeping them in sync. Swift 6.2 introduces a first-class Swift–Java interoperability layer via the open-source Swift–Java package, a bridge that finally feels less like a rickety rope and more like a solid handshake between two old friends. In this article, we’ll explore how the swift-java package makes it possible to call Java APIs from Swift with minimal ceremony, dive into real-world examples, and uncover some of the hidden quirks that can turn this smooth ride into a bumpy road. Grab your favorite coffee, and let’s demystify the art of weaving Swift and Java together—hacky workarounds and all.

Prerequisites

Before diving into the code, make sure you’ve got the right toolkit:

  • JDK 24: Java Development Kit 24 is required to compile and run any Java code you’ll be bridging into. If you haven’t installed it yet, grab it from the Oracle website or use your favorite package manager (SDKMAN! or Homebrew both have you covered).
  • Xcode 26 Beta: Swift–Java interop relies on some of the latest compiler hooks only available in Xcode 26 Beta. Download it from Apple’s developer portal and switch your toolchain in Xcode’s Preferences > Components.

Once you’ve got JDK 24 and Xcode 26 Beta installed, you’re ready to move on to setting up the swift-java package itself.

Project structure

Before we jump into the code, here’s a bird’s-eye view of our repo layout—just enough to keep your bearings:

.
├── java-lib
│   └── src
│       └── com
│           └── example
│               └── Greeting.java
├── sim
│   ├── Sources
│   │   ├── sim
│   │   │   ├── Simulation.swift
│   │   │   └── swift-java.config
│   │   └── sim-app
│   │       └── main.swift
│   ├── Package.swift
└── Makefile
  • java-lib/src/com/example/Greeting.java: A simple Java class exposing sayHello().
  • sim/Sources/sim/Simulation.swift: Swift entry point demonstrating interop calls.
  • sim/Sources/sim/swift-java.config: Configuration file for the swift-java bridge.
  • sim/Sources/sim-app/main.swift: Sample app wiring everything together.
  • Package.swift & Makefile: Build definitions to compile Java and Swift targets in one go.

Java side

Let’s kick off with our Java side. In java-lib/src/com/example/Greeting.java, we have a minimal class exposing a static greet method:

package com.example;

public class Greeting {
    public static String greet(String name) {
        return "Hello, " + name + "!";
    }
}

Nothing magical here—just a single entry point for Swift to invoke. We’ll compile this into a JAR and point our Swift bridge at it in the next section.

Java Build Step

Before we wire up Swift, let’s compile and package our Java library. We’ll use the java target from the Makefile:

JAVAC   := javac
JAR     := jar
BUILD   := build

.PHONY: java

java:
	@echo "Compiling Java library..."
	mkdir -p $(BUILD)/java
	$(JAVAC) -d $(BUILD)/java java-lib/src/com/example/Greeting.java
	$(JAR) cf $(BUILD)/java/Greeting.jar -C $(BUILD)/java com/example/Greeting.class

Run make java to produce build/java/Greeting.jar (which we’ll reference from Swift).

Preparing Interop

To wire Swift and Java together, we need a swift-java.config file in sim/Sources/sim/:

{
  "classpath": "../build/java/Greeting.jar",
  "classes": {
    "com.example.Greeting": "Greeting"
  }
}
  • classpath: Points to the JAR we built earlier.
  • classes: Maps the fully-qualified Java class name to the Swift-friendly alias.

With this in place, running swift build will auto-generate the necessary Swift wrappers for Greeting.

Swift Simulator

Next, let’s inspect our Swift-side adapter in sim/Sources/sim/Simulation.swiftbut pay extra attention to the classpath—this tripped me up for two days:

import JavaKit
import Foundation

public struct Simulation {
    public static func run(with name: String) {
        do {
            // 🚨 CLASSPATH IS EVERYTHING 🚨
            // Any custom JAR not on the standard Java classpath *must* be listed here.
            let jvm = try JavaVirtualMachine.shared(
                classpath: ["../build/java/Greeting.jar"] // <- this line is non-negotiable
            )
            let jniEnvironment = try jvm.environment()
            let greetingClass = try JavaClass<Greeting>(environment: jniEnvironment)

            print(greetingClass.greet(name))
        } catch {
            print("Failure: \(error)")
        }
    }
}

Why the fuss about classpath?

  • JavaKit spawns a JVM in-process and only loads classes it knows about.
  • If your JAR isn’t explicitly on the classpath array, it won’t be found—and you’ll get inexplicable ClassNotFounderrors.
  • Even if it lives in your project, Swift’s build system won’t magically add it for you.

Emphasizing once more: any JAR outside of the standard Java classpath must go into that classpath array.

Static vs. Instance Methods

  • Static methods (like our greet) require the JavaClass<T> wrapper to invoke directly on the class.

Instance methods (non-static) can be called by constructing an object:

let greetingObj = try Greeting(environment: jniEnvironment)
print(try greetingObj.someInstanceMethod())

With that settled, you’re ready to wire up and run your simulation.

Command-Line Entry Point

Finally, let’s glue everything together in our simple CLI app. In sim/Sources/sim-app/main.swift, we have:

import sim

let name = CommandLine.arguments.dropFirst().first ?? "World"
Simulation.run(with: name)

Here’s what’s happening:

  1. Import the module: We bring in our sim package, which contains the Simulation struct.
  2. Parse the argument: We drop the executable name and take the first argument as the user’s name, defaulting to "World" if none is provided.
  3. Run the simulation: Pass the name into Simulation.run, which boots the JVM, invokes the Java greet method, and prints the result.

We’ll wire up the Swift side via our Package.swift in the next paragraph, so hold off on running for now.

Swift Package Configuration

Below is our Package.swift (swift-tools-version: 6.2) lifted from the java-swift examples, with extra logic to locate JAVA_HOME and configure JNI include paths:

// swift-tools-version: 6.2
import PackageDescription
import class Foundation.FileManager
import class Foundation.ProcessInfo

// Note: the JAVA_HOME environment variable must be set to point to where
// Java is installed, e.g.,
//   /Library/Java/JavaVirtualMachines/jdk-24.jdk/Contents/Home
func findJavaHome() -> String {
  if let home = ProcessInfo.processInfo.environment["JAVA_HOME"] {
    return home
  }

  // Workaround for IDEs that don’t pick up shell envs during builds
  let path = "\(FileManager.default.homeDirectoryForCurrentUser.path()).java_home"
  if let home = try? String(contentsOfFile: path, encoding: .utf8) {
    return home.trimmingCharacters(in: .newlines)
  }

  fatalError("Please set JAVA_HOME to your JDK installation.")
}
let javaHome = findJavaHome()

let javaIncludePath = "\(javaHome)/include"
#if os(Linux)
  let javaPlatformIncludePath = "\(javaIncludePath)/linux"
#elseif os(macOS)
  let javaPlatformIncludePath = "\(javaIncludePath)/darwin"
#else
  #error("Only macOS and Linux are supported currently.")
#endif

let package = Package(
  name: "java-sim",
  platforms: [.macOS(.v15)],
  dependencies: [
    .package(url: "https://github.com/swiftlang/swift-java.git", branch: "main"),
  ],
  targets: [
    .target(
      name: "sim",
      dependencies: [
        .product(name: "JavaKit", package: "swift-java"),
        .product(name: "JavaKitJar", package: "swift-java"),
        .product(name: "JavaKitCollection", package: "swift-java"),
      ],
      exclude: ["swift-java.config"],
      swiftSettings: [
        .unsafeFlags(["-I\(javaIncludePath)", "-I\(javaPlatformIncludePath)"])
      ],
      plugins: [
        // Auto-generates Swift wrappers for our Java classes into `.build`
        .plugin(name: "SwiftJavaPlugin", package: "swift-java"),
      ]
    ),
    .executableTarget(
      name: "sim-app",
      dependencies: ["sim"]
    )
  ]
)

Environment Variables

JAVA_HOME must point to your JDK 24 install and be exported in your shell (e.g., in ~/.zshrc):

export JAVA_HOME="/Library/Java/JavaVirtualMachines/jdk-24.jdk/Contents/Home"

Plugins Explained

  • The SwiftJavaPlugin integrates into SwiftPM’s build process to generate JNI bindings.
  • Generated Swift wrapper code for your Java classes appears under SwiftPM’s hidden .build directory—no manual glue code needed.

Once JAVA_HOME is set and you’ve updated Package.swift, we’re still not running swift build directly. Instead, we’ll modify our Makefile in the next section so that invoking make will handle both Java compilation and Swift package builds seamlessly—no manual commands required.

Unified Build via Makefile

Below is our updated Makefile that ties everything together:

JAVAC   := javac
JAR     := jar
BUILD   := build

.PHONY: all java swift run clean

all: java swift

java:
	@echo "Compiling Java library..."
	mkdir -p $(BUILD)/java
	$(JAVAC) -d $(BUILD)/java java-lib/src/com/example/Greeting.java
	$(JAR) cf $(BUILD)/java/Greeting.jar -C $(BUILD)/java com/example/Greeting.class

swift:
	@echo "Building Swift package (auto-generates wrappers)..."
	cd sim && swift build

run:
	@echo "Running sample app..."
	cd sim && swift run sim-app $(name)

clean:
	rm -rf $(BUILD) sim/.build

Build Order:

  1. make – compiles Java and builds the Swift package (including wrapper generation).
  2. make run name=John – runs the CLI app with John as the argument, printing Hello, John!.

With this in place, you only need to type make run name=YourName to go from code to interop output in one command.

Summary

In this guide, we explored how Swift 6.2’s swift-java package bridges the gap between Swift and Java, enabling seamless method calls across runtimes. We started by setting up our environment—JDK 24 and Xcode 26 Beta—before compiling our simple Greeting Java class into a JAR. We then configured the interop via swift-java.config, emphasizing the critical role of the classpath in avoiding ClassNotFound errors. On the Swift side, we built a Simulation struct using JavaVirtualMachine.shared and JavaClass<Greeting>, highlighted the difference between static and instance method invocation, and provided a minimal CLI entry point in main.swift. We demonstrated how the SwiftJavaPlugin in Package.swift auto-generates JNI bindings (with JAVA_HOME correctly exported) and finalized the setup by unifying the workflow in a Makefile. With a single make run name=YourName, you can now invoke Java code from Swift effortlessly—no more rope bridges, just a solid handshake between two ecosystems.

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland