Article
Tidy up your Observable Streams with Kotlin’s Sealed Classes
August 30, 2017
If you have ever read a sterile how-to example of using RxJava in your network layer, you might find this familiar:
userService.updateEmail("newEmail@example.com")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new SingleObserver<UserInfo>() {
//...
})
Here we have some asynchronous call to update an email address, and an Observer
standing ready to handle the result. As samples go, it’s not trying to solve more problems than absolutely necessary while demonstrating the basics.
But just beyond the basics are the every-day error conditions:
- Permission Denied
- Sign In Required
- Invalid Email
You might think to handle your error states inside onError(Throwable t)
, but there are good reasons not to . onError()
should be reserved for when you’re unlikely to gracefully recover. However, we can gracefully handle errors like permission rejection or invalid inputs. Rather than violently terminating the stream with an Exception
, these error conditions should be pushed through onSuccess().
But how can we model the success case and all the expected error cases in one stream of UserInfo
? Kotlin has a beautiful solution, but before looking at that, lets examine a Java approach and observe its problems.
The Java Way
Suppose we combine both successes and expected errors into a composite object, such that you can inspect the payload for success or failure in a single model:
onSuccess(Either<UserInfo, EmailUpdateError> result)
Here an Either
holds two Optional<T>
fields and allows you check which is present.
In fact, Retrofit’s Response (and Result ) wrappers are built with a similar idea. If you opt-in, Retrofit’s RxJava Call Adapter will wrap most non-200s in Result<T>
and send them through onSuccess()
.
But Java makes handling compound Nullable
types very ugly. Response<T>
does a fine job guarding against nullability, but any composite object like Response<T>
or Either<Success, Failure>
relies on the consuming developer to manually access Nullable
(or Optional<T>
) fields:
if (either.isSuccess()) {
UserInfo userInfo = either.success().get();
// handle success
} else {
EmailUpdateFailure failure = either.failure().get();
// handle failure
}
This increases branching, depends on documentation contracts, and is verbose. Any model type of your own creation will have the same core problem.
And don’t even think about using .zipWith()
to coalesce multiples:
Yuck.
Use Kotlin’s Sealed Classes
With a sealed class, you can define each case as its own type.
Notice they each may have unique properties. Unlike a typical class hierarchy, the possibilities are bounded and therefore the compiler can help you check your work.
Handling an EmailUpdate
is easy. Use when
, an upgrade over Java’s switch
:
override fun onSuccess(result: EmailUpdate) {
when (result) {
is Success -> publish(result.updatedProfile)
PermissionDenied -> onPermissionDenied()
SignInToContinue -> showSignInDialog()
is EmailInvalid ->
showEmailError(result.input, result.error)
}
}
Isn’t that better? You can access the EmailUpdate
's properties in each case without nullability. Kotlin will not only smart-cast to the appropriate type, it can also ensure that your when
statement is exhaustive (if you’ve prefixed with return
). When another developer adds a case to EmailUpdate
, the compiler will prevent them from neglecting that additional possibility.
Sealed classes (and more broadly, algebraic data types ) are not a new idea. Elm
’s Union Types and F#
’s Discriminated Unions are powerful versions of the same concept. Functional languages in particular have a lot to gain by supporting terse union-type syntax: functions return one value! Swift’s enums also allow for type-safe handling of non-homogenous cases.
Don’t let compound return types clutter your stream semantics. Start using sealed classes in Kotlin!
Collin writes code with Livefront and he thanks you for reading.