Javable: generate Java-friendly wrappers for Kotlin with KSP

Javable: generate Java-friendly wrappers for Kotlin with KSP

TL;DR: Javable is a Kotilin Symbol Processing (KSP) processor that generates Java-friendly wrappers for Kotlin classes. Annotate your class with @JavaApi and your functions with @AsyncJavaApi or @BlockingJavaApi, and KSP generates CompletableFuture-based async adapters, blocking wrappers, and Stream-based Flow collectors — with correct CoroutineScope lifecycle management.

Imagine you are a Kotlin library developer. You have created a beautiful API and want to conquer the JVM world. But then you realize that everyone is still using Java.

When you look at your API from a Java developer’s point of view, it no longer looks as clean:

  • Data classes with 10+ parameters. Without Kotlin named parameters, calling them from Java becomes a mess. You need fluent builders.
  • Methods returning Flow. The Flow type is a Kotlin Coroutines concept — Java has no native equivalent.
  • Suspending functions that read like prose in Kotlin but surface as ugly methods with a raw $continuation parameter in Java.

You start writing Java-specific extensions using Consumer callbacks and CompletableFuture, wrapping async code with runBlocking { } and GlobalScope.future { /* suspending function call */ }. But subtle bugs follow: using GlobalScope violates structured concurrency and silently drops exceptions.

If you handle structured concurrency correctly, you still end up with a lot of repetitive boilerplate. Even if an AI agent writes it quickly — there is an established pattern in the repo — it is still non-deterministic, and AI can make mistakes.

I faced this problem and created Javable — a small KSP processor that generates Java-friendly wrappers for Kotlin classes and functions.

How it works

Javable uses three annotations:

  • @JavaApi — placed on the class. Controls whether a Java wrapper (*Java.java), a Kotlin wrapper (*Kotlin.kt), or both should be generated, and whether the wrapper should implement AutoCloseable.
  • @AsyncJavaApi — placed on suspend functions or functions returning Flow<T>. Generates a CompletableFuture<T>, CompletionStage<T>, or blocking Stream<T> method.
  • @BlockingJavaApi — placed on suspend functions. Generates a plain synchronous method via runBlocking, suitable for dedicated worker threads or Java 21 virtual threads.

Non-suspend public functions are forwarded unchanged in both wrappers.

Setup

Add the KSP plugin and Javable dependencies to your build.gradle.kts:

kotlin
 1plugins {
 2    id("com.google.devtools.ksp") version "[LATEST VERSION]"
 3}
 4
 5dependencies {
 6    implementation("me.kpavlov.javable:javable-annotations:[LATEST VERSION]")
 7    ksp("me.kpavlov.javable:javable-ksp:[LATEST VERSION]")
 8
 9    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
10    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.10.2")
11}

Javable is not yet on Maven Central. Build and publish locally first: ./gradlew publishToMavenLocal, then add mavenLocal() to your repositories.

Annotating a class

Here is a minimal example:

kotlin
 1@JavaApi(javaWrapper = true, autoCloseable = true)
 2class Calculator {
 3
 4    @AsyncJavaApi
 5    suspend fun add(a: Int, b: Int): Int {
 6        delay(10L)
 7        return a + b
 8    }
 9
10    @BlockingJavaApi
11    suspend fun multiply(a: Int, b: Int): Int {
12        delay(10L)
13        return a * b
14    }
15}

KSP generates CalculatorJava.java with:

  • add(int a, int b): CompletableFuture<Integer> — uses the wrapper’s built-in CoroutineScope
  • add(int a, int b, Executor executor): CompletableFuture<Integer> — runs on the caller-supplied executor
  • multiply(int a, int b): int throws InterruptedException — synchronous, blocks the calling thread

From Java:

java
1try (var calc = new CalculatorJava()) {
2    int result = calc.multiply(3, 4);               // blocking
3    calc.add(3, 4).thenAccept(System.out::println); // async
4}

Because autoCloseable = true, the wrapper implements AutoCloseable and can be used in a try-with-resources block. Closing it cancels the internal CoroutineScope and waits for all child coroutines to finish.

Exposing Flow as Stream

For functions returning Flow<T>, use @AsyncJavaApi(wrapperType = JavaWrapperType.STREAM):

kotlin
 1@JavaApi(javaWrapper = true)
 2class EventSource {
 3
 4    @AsyncJavaApi(wrapperType = JavaWrapperType.STREAM)
 5    fun events(): Flow<String> = flow {
 6        emit("started")
 7        delay(100L)
 8        emit("done")
 9    }
10}

The generated method returns Stream<String> and collects the entire flow with runBlocking before returning. This buffers all elements in memory — for large or infinite flows, a reactive adapter (Flux, Publisher) is planned for a future release.

Annotation reference

@JavaApi (class level)

ParameterDefaultEffect
kotlinWrappertrueGenerate *Kotlin.kt
javaWrapperfalseGenerate *Java.java
autoCloseablefalseJava wrapper implements AutoCloseable

@AsyncJavaApi (function level)

wrapperTypeApplies toGenerated return type
COMPLETABLE_FUTURE (default)suspend functionsCompletableFuture<T>
COMPLETION_STAGEsuspend functionsCompletionStage<T>
STREAMfun or suspend fun returning Flow<T>Stream<T> (blocking collect)

For COMPLETABLE_FUTURE and COMPLETION_STAGE, two overloads are generated: one using the wrapper’s built-in scope, one accepting a caller-supplied Executor. For STREAM, a single blocking method with no executor overload.

@BlockingJavaApi (function level)

Wraps a suspend function as a plain synchronous call via runBlocking. The generated method declares throws InterruptedException. If both @AsyncJavaApi and @BlockingJavaApi are placed on the same function, @AsyncJavaApi takes precedence.

Scope and resource management

When the wrapper holds a CoroutineScope, close() cancels the scope and waits for all child coroutines to finish. The Kotlin wrapper uses runBlocking { job?.join() }; the Java wrapper does the same with a 5-second timeout and surfaces failures rather than swallowing them.

Whether a scope is created depends on the annotations used. A Java wrapper gets a scope whenever it has at least one @AsyncJavaApi method with COMPLETABLE_FUTURE or COMPLETION_STAGE return type; STREAM and @BlockingJavaApi methods don’t need one. Setting autoCloseable = true goes one step further and makes the Java wrapper implement AutoCloseable, so the scope is cleaned up explicitly by the caller. A Kotlin wrapper follows the same rule — scope is present only when there are Future/Stage methods — and always implements AutoCloseable in that case.

Current status

Javable is at 0.1.0-SNAPSHOT and not yet on Maven Central. Check the source code, file issues, or contribute. Reactive adapter support for infinite Flow streams is on the roadmap.

Is Kotlin–Java interop a pain point on your team? I’d be curious to hear what approach you use today.

References

Konstantin Pavlov

Konstantin Pavlov

Software Engineer working with Java, Kotlin, Swift, and AI. Focusing on software architecture and building AI-infused apps. Passionate about testing and Open-Source projects.