Sealed classes and interfaces in Kotlin
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:
Alternatively, you could use an abstract class:
With either of these, we know that when a function returns Result
, it can be Success
or Failure
.
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
.
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.
When we use the
sealed
modifier before a class, it makes this class abstract already, so we don’t use theabstract
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.
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.
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.
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.
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.
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:
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.
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.
This requires the kotlin-reflect
dependency. More about reflection in Advanced Kotlin.
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.