article banner

Sealed classes and interfaces in Kotlin

This is a chapter from the book Kotlin Essentials. You can find it on LeanPub or Amazon. It is also available as a course.

Classes and interfaces in Kotlin are not only used to represent a set of operations or data; we can also use classes and inheritance to represent hierarchies through polymorphism. For instance, let's say that you send a network request; as a result, you either successfully receive the requested data, or the request fails with some information about what went wrong. These two outcomes can be represented using two classes that implement an interface:

interface Result class Success(val data: String) : Result class Failure(val exception: Throwable) : Result

Alternatively, you could use an abstract class:

abstract class Result class Success(val data: String) : Result() class Failure(val exception: Throwable) : Result()

With either of these, we know that when a function returns Result, it can be Success or Failure.

val result: Result = getSomeData() when (result) { is Success -> handleSuccess(result.data) is Failure -> handleFailure(result.exception) }

The problem is that when a regular interface or abstract class is used, there is no guarantee that its defined subclasses are all possible subtypes of this interface or abstract class. Someone might define another class and make it implement Result. Someone might also implement an object expression that implements Result.

class FakeSuccess : Result val res: Result = object : Result {}

A hierarchy whose subclasses are not known in advance is known as a non-restricted hierarchy. For Result, we prefer to define a restricted hierarchy, which we do by using a sealed modifier before a class or an interface0.

sealed interface Result class Success(val data: String) : Result class Failure(val exception: Throwable) : Result // or sealed class Result class Success(val data: String) : Result() class Failure(val exception: Throwable) : Result()

When we use the sealed modifier before a class, it makes this class abstract already, so we don’t use the abstract modifier.

There are a few requirements that all sealed class or interface children must meet:

  • they need to be defined in the same package and module where the sealed class or interface is,
  • they can't be local or defined using object expression.

This means that when you use the sealed modifier, you control which subclasses a class or interface has. The clients of your library or module cannot add their own direct subclasses2. No one can quietly add a local class or object expression that extends a sealed class or interface. Kotlin has made this impossible. The hierarchy of subclasses is restricted.

Sealed interfaces were introduced in more recent versions of Kotlin to allow classes to implement multiple sealed hierarchies. The relation between a sealed class and a sealed interface is similar to the relation between an abstract class and an interface. The power of classes is that they can keep a state (non-abstract properties) and control their members' openness (can have final methods and properties). The power of interfaces is that a class can inherit from only one class but it can implement multiple interfaces.

Sealed classes and when expressions

Using when as an expression must return some value, so it must be exhaustive. In most cases, the only way to achieve this is to specify an else clause.

fun commentValue(value: String) = when { value.isEmpty() -> "Should not be empty" value.length < 5 -> "Too short" else -> "Correct" } fun main() { println(commentValue("")) // Should not be empty println(commentValue("ABC")) // Too short println(commentValue("ABCDEF")) // Correct }

However, there are also cases in which Kotlin knows that we have specified all possible values. For example, when we use a when-expression with an enum value and we compare this value to all possible enum values.

enum class PaymentType { CASH, CARD, CHECK } fun commentDecision(type: PaymentType) = when (type) { PaymentType.CASH -> "I will pay with cash" PaymentType.CARD -> "I will pay with card" PaymentType.CHECK -> "I will pay with check" }

The power of having a finite set of types as an argument makes it possible to have an exhaustive when with a branch for every possible value. In the case of sealed classes or interfaces, this means having is checks for all possible subtypes.

sealed class Response<out V> class Success<V>(val value: V) : Response<V>() class Failure(val error: Throwable) : Response<Nothing>() fun handle(response: Response<String>) { val text = when (response) { is Success -> "Success with ${response.value}" is Failure -> "Error" // else is not needed here } print(text) }

Also, IntelliJ automatically suggests adding the remaining branches. This makes sealed classes very convenient when we need to cover all possible types.

Note that when an else clause is not used and we add another subclass of this sealed class, the usage needs to be adjusted by including this new type. This is convenient in local code as it forces us to handle this new class in exhaustive when expressions. The inconvenient part is that when this sealed class is part of the public API of a library or shared module, adding a subtype is a breaking change because all modules that use exhaustive when need to cover one more possible type.

Sealed vs enum

Enum classes are used to represent a set of values. Sealed classes or interfaces represent a set of subtypes that can be made with classes or object declarations. This is a significant difference. A class is more than a value. It can have many instances and can be a data holder. Think of Response: if it were an enum class, it couldn't hold value or error. Sealed subclasses can each store different data, whereas an enum is just a set of values.

Use cases

We use sealed classes whenever we want to express that there is a concrete number of subclasses of a class.

sealed class MathOperation class Plus(val left: Int, val right: Int) : MathOperation() class Minus(val left: Int, val right: Int) : MathOperation() class Times(val left: Int, val right: Int) : MathOperation() class Divide(val left: Int, val right: Int) : MathOperation() sealed interface Tree class Leaf(val value: Any?) : Tree class Node(val left: Tree, val right: Tree) : Tree sealed interface Either<out L, out R> class Left<out L>(val value: L) : Either<L, Nothing> class Right<out R>(val value: R) : Either<Nothing, R> sealed interface AdView object FacebookAd : AdView object GoogleAd : AdView class OwnAd(val text: String, val imgUrl: String) : AdView

The key benefit is that when-expression can easily cover all possible types in a hierarchy using is-checks. A when-condition with a sealed element as a value ensures the compiler performs exhaustive type checking, and our program can only represent valid states.

fun BinaryTree.height(): Int = when (this) { is Leaf -> 1 is Node -> maxOf(this.left.height(), this.right.height()) }

However, expressing that a hierarchy is restricted improves readability. Finally, when we use the sealed modifier, we can use reflection to find all the subclasses1:

sealed interface Parent class A : Parent class B : Parent class C : Parent fun main() { println(Parent::class.sealedSubclasses) // [class A, class B, class C] }

Summary

Sealed classes and interfaces should be used to represent restricted hierarchies. The when-statement makes it easier to handle each possible sealed subtype and, as a result, to add new methods to sealed elements using extension functions. Abstract classes leave space for new classes to join this hierarchy. If we want to control what the subclasses of a class are, we should use the sealed modifier.

Next, we will talk about the last special kind of class that is used to add extra information about our code elements: annotations.

0:

Restricted hierarchies are used to represent values that could take on several different but fixed types. In other languages, restricted hierarchies might be represented by sum types, coproducts, or tagged unions.

1:

This requires the kotlin-reflect dependency. More about reflection in Advanced Kotlin.

2:

You could still declare an abstract class or an interface as a part of a sealed hierarchy that the client would be able to inherit from another module.