Skip to main content
  1. Posts/

Javable: generate Java-friendly wrappers for Kotlin with KSP

Image generated by Nano Banana 2

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:

plugins {
    id("com.google.devtools.ksp") version "[LATEST VERSION]"
}

dependencies {
    implementation("me.kpavlov.javable:javable-annotations:[LATEST VERSION]")
    ksp("me.kpavlov.javable:javable-ksp:[LATEST VERSION]")

    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.10.2")
}

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:

@JavaApi(javaWrapper = true, autoCloseable = true)
class Calculator {

    @AsyncJavaApi
    suspend fun add(a: Int, b: Int): Int {
        delay(10L)
        return a + b
    }

    @BlockingJavaApi
    suspend fun multiply(a: Int, b: Int): Int {
        delay(10L)
        return a * b
    }
}

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:

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

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):

@JavaApi(javaWrapper = true)
class EventSource {

    @AsyncJavaApi(wrapperType = JavaWrapperType.STREAM)
    fun events(): Flow<String> = flow {
        emit("started")
        delay(100L)
        emit("done")
    }
}

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 #