Effective Kotlin Item 39: Use sealed classes and interfaces to express restricted hierarchies
Sealed classes and interfaces represent restricted hierarchies, which means hierarchies with a concrete set of classes known in advance. Great examples are the
BinaryTree (that is either
Either (that is either
Right). Such classes or interfaces have a concrete subset of children, that will never change.
Sealed interfaces were introduced in Kotlin 1.5. Before that version, we had to use sealed classes, and its subclasses had to be defined in the same file.
Sealed classes are also used to represent hierarchies that might change in the future, but are considered final now. For instance, a set of operations we support, or messages an actor accepts.
Notice, that when a class does not hold any data, we use an object declaration. It is an optimization to have one instance for all usages. Both creation and comparison are easier.
One might say that a sealed class or interface is a Kotlin way to express union types (sum types or coproducts) — a set of alternatives. For instance,
Either is either
Right, but never both.
Sealed classes are abstract classes, but because of their characteristics, they also have some additional restrictions on their subclasses:
- they need to be defined in the same package and module, where the sealed class is,
- they can't be local nor anonymous objects.
This means that when you use the
sealed modifier, you control what subclasses a class or interface has. The clients of your library or module cannot add their own. No one can quietly add a local class or object expression extending this class. The hierarchy of subclasses is restricted.
Sealed classes and when expressions
The usage of
when as an expression, forces the user to return a value for every branch. In most cases, it cannot be guaranteed any other way but by adding an
else clause. The power of having a finite type as an argument allows to have an exhaustive
when with a branch for every possible value, which with sealed classes means
is checks for all possible subtypes. Also, IntelliJ automatically suggests adding remaining branches. Those two make sealed classes very convenient to check for all possible types.
Notice that when the
else clause is not used, and when we add another subclass of this sealed class, the usage needs to be adjusted by including this new type. This is convenient in a local code - forces us to handle the new class in most of the cases where we need to. The inconvenient part is, that when this sealed class is a part of the public API of some library or shared module, adding a subtype is a breaking change.
Sealed classes and interfaces should be used to represent restricted hierarchies. They make it easier to handle each possible value, and as a result to add new methods using extension functions. Abstract classes leave space for new classes joining this hierarchy. If we want to control what the subclasses of a class are, we should use the
sealed modifier. We use
abstract mainly when we design for inheritance.