Sealed with a class

Kotlin Vocabulary — Sealed Classes

Florina Muntenescu
Android Developers

--

Often we need to represent a limited set of possibilities; a web request either succeeds or fails, a User can only be a Pro-User or a standard user.

To model this we could use an enum, but this carries a number of limitations. Enum classes only allow a single instance of each value and can’t encode more information on each type, e.g. an Error case having an associated Exceptionproperty.

You could use an abstract class and a number of extensions but this loses the restricted set of types advantage brought by enums. Sealed classes provide the best of both worlds: the freedom of representation of abstract classes and the restricted set of types of enums. Read on to find out more about sealed classes or, if you prefer a video, check it out here:

The basics of sealed classes

Like abstract classes, sealed classes allow you to represent hierarchies. The child classes can be any type of class: a data class, an object, a regular class or even another sealed class. Unlike abstract classes, you have to define these hierarchies in the same file or as nested classes.

// Result.ktsealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}

Trying to extend the sealed class outside the file it was defined in yields a compile error:

Cannot access ‘<init>’: it is private in Result

Forgetting a branch?

Often we want to handle all possible types:

when(result) {
is Result.Success -> { }
is Result.Error -> { }
}

But what if someone adds a new type of Result: InProgress:

sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
object InProgress : Result<Nothing>()
}

Rather than relying on our memory or an IDE search to ensure that all when usages handle the new class the compiler can give us an error if a branch isn’t covered. when, like the if statement, only requires us to cover all options (i.e. to be exhaustive) by producing a compiler error when it’s used as an expression:

val action = when(result) {
is Result.Success -> { }
is Result.Error -> { }
}

When expression must be exhaustive, add necessary ‘is InProgress’ branch or else branch instead

To get this nifty benefit even when we’re using when as a statement, add this helper extension property:

val <T> T.exhaustive: T
get() = this

So now, by adding .exhaustive, if a branch is missing, the compiler will give us the same error we saw previously.

when(result){
is Result.Success -> { }
is Result.Error -> { }
}.exhaustive

IDE auto-complete

As all sub-types of a sealed class are known, the IDE can fill all possible branches of a when statement for us:

This feature really shines with more complex sealed classes hierarchies as the IDE can recognise all branches:

sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
sealed class Error(val exception: Exception) : Result<Nothing>() {
class RecoverableError(exception: Exception) : Error(exception)
class NonRecoverableError(exception: Exception) :
Error(exception)
}
object InProgress : Result<Nothing>()
}

This is the type of functionality that can’t be implemented with abstract classes as the compiler doesn’t know the inheritance hierarchy; therefore the IDE can’t generate the branches.

Under the hood

So what makes sealed classes behave as they do? Let’s see what’s going on in the decompiled Java code:

sealed class Result
data class Success(val data: Any) : Result()
data class Error(val exception: Exception) : Result()
@Metadata(

d2 = {“Lio/testapp/Result;”, “T”, “”, “()V”, “Error”, “Success”, “Lio/testapp/Result$Success;”, “Lio/testapp/Result$Error;” …}
)
public abstract class Result {
private Result() {}
// $FF: synthetic method
public Result(DefaultConstructorMarker $constructor_marker) {
this();
}
}

The metadata of the sealed class keeps the list of the child classes, allowing the compiler to use this information where needed.

The Result is implemented as an abstract class with two constructors:

  • A private default constructor
  • A synthetic constructor that can only be used by the Kotlin compiler

So, this means that no other class can directly call the constructor. If we look at the decompiled code of the Success class, we see that it calls through to the synthetic constructor:

public final class Success extends Result {
@NotNull
private final Object data

public Success(@NotNull Object data) {

super((DefaultConstructorMarker)null);
this.data = data;
}

Start using sealed classes to model restricted class hierarchies allowing the compiler and IDE to help you avoid type errors.

--

--