Kotlin sealed class API (for Java consumption)

·

5 min read

These days developers usually write an Android library/SDK in Kotlin. As an SDK developer, one should keep in mind that the API is usable and friendly when consumed from Java language. In reality, there are still tons of Android apps written in Java for a variety of reasons.

Recently while designing an API, I introduced a Kotlin sealed class hierarchy as a return type for an API. To be specific, I created a Result sealed class with Success and Failure child classes. If you're not aware of this pattern, then you can read more about it here. There are also standalone libraries created for this purpose. kotlin-result is one of them.

I stumbled upon an issue while using this new API in a Java-based app. I had to write unidiomatic Java code that I didn't like. I wasn't too sure about how to improve the API to make Java calling code idiomatic. I posted a question about the same on Kotlin Slack [^1] . Luckily a person named Jason Cobb helped and gave few ideas. With that help, after a couple of tries, I made my Kotlin based API more Java-friendly.

Let's look at the issue with an example. Let's say we want to create a function called prepare() that initializes some logic. It returns either a success or a failure with an error message. With that in mind, we come up with an API like this,

sealed class PrepareResult {
    object Success : PrepareResult()
    data class Failure(val error: String) : PrepareResult()
}

object TestClass {
    @JvmStatic
    fun prepare(): PrepareResult {
        return if (Random.nextBoolean()) {
            PrepareResult.Success
        } else {
            PrepareResult.Failure("Some reason")
        }
    }
}

Now let's update the demo app for our library to use this new API.

when (val result = TestClass.prepare()) {
    is PrepareResult.Success -> {
        // Handle success
    }
    is PrepareResult.Failure -> {
        val error = result.error
        // Handle failure with error
    }
}

Usually, at this point, there would be a happy-go feeling to consider it done. But let's not forget about our old friend Java. When we use the API in Java, it looks like this,

PrepareResult response = TestClass.prepare();
if (response instanceof PrepareResult.Failure) {
    String error = ((PrepareResult.Failure) response).getError();
    // Handle failure with error
} else if (response instanceof PrepareResult.Success) {
    // Handle success
}

Notice how we have to rely on instanceof along with if-else to deal with Kotlin's sealed class objects. Here the else if is unnecessary, but I've added it to demonstrate that it would be unreadable if there are more classes in the hierarchy.


First Pass

Jason suggested introducing a method on the base sealed class. The idea is to add something that would seem natural to Java developers. So let's add an abstract onSuccessOrElse() method with two lambdas for success and failure cases each. Java's Optional API uses orElse pattern.

sealed class PrepareResult {
    abstract fun onSuccessOrElse(onSuccess: () -> Unit, onError: (String) -> Unit)

    object Success : PrepareResult() {
        override fun onSuccessOrElse(onSuccess: () -> Unit, onError: (String) -> Unit) {
            onSuccess.invoke()
        }
    }

    data class Failure(val error: String) : PrepareResult() {
        override fun onSuccessOrElse(onSuccess: () -> Unit, onError: (String) -> Unit) {
            onError.invoke(error)
        }
    }
}

Now instead of worrying about the type of the PrepareResult, we can use the new onSuccessOrElse method in the Java code.

response.onSuccessOrElse(() -> {
        // Handle success
        return Unit.INSTANCE;
}, error -> {
        // Handle failure with error
        return Unit.INSTANCE;
});

It improves the Java calling code, but there is still a problem. Notice the use of return Unit.INSTANCE;. In Kotlin, when we declare a lambda as (String) -> Unit, from Java's point of view, it's equivalent to Function<String, Unit>. Similarly () -> Unit becomes Function<Unit> in Java. It means that on the Java side, we had to return an instance of Unit when we implemented those functions, and that is again a bit unidiomatic.

Second Pass

To avoid returning the Unit.INSTANCE, we have to improve our API further. We can define a named, single-abstract method (SAM) interface instead of lambda type. Until Kotlin version 1.4.0, we couldn't use lambda syntax for a SAM interface in Kotlin, but now we can as Kotlin released SAM conversions for Kotlin interfaces.

So let's introduce two functional interfaces to handle success and failure cases. Notice the fun keyword below!

fun interface SuccessHandler {
    fun invoke()
}

fun interface FailureHandler {
    fun invoke(error: String)
}

With that, let's update our onSuccessOrElse method to use the interfaces instead of Lambdas,

sealed class PrepareResult {
    abstract fun onSuccessOrElse(onSuccess: SuccessHandler, onError: FailureHandler)

    object Success : PrepareResult() {
        override fun onSuccessOrElse(onSuccess: SuccessHandler, onError: FailureHandler) {
            onSuccess.invoke()
        }
    }

    data class Failure(val error: String) : PrepareResult() {
        override fun onSuccessOrElse(onSuccess: SuccessHandler, onError: FailureHandler) {
            onError.invoke(error)
        }
    }
}

Since our invoke method in the interface doesn't return Unit like our Lambda, our Java code doesn't need to return anything anymore. Our API is Java-friendly now :)

response.onSuccessOrElse(() -> {
        // Handle success
}, error -> {
        // Handle failure with error
});

Note that these changes don't impact our original Kotlin calling code. We can continue using the when block with sealed class hierarchy as usual.

You may generalize this Result class to use across your library. In that case, two functional interfaces won't hurt and would make your API look better and well thought.

I would also recommend having and maintaining a Java-based demo app project for your Android library. That way, you'd always make sure to check your API against Java calling code and catch interoperability issues like this.

PS: Thanks to Victoria and Gabor for providing feedback <3

[^1]: Sign-up for Kotlin Slack here if you'd like.