Kotlin sealed class API (for Java consumption)
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.