What are sealed classes in Kotlin?
Sealed classes are easier to understand when you understand the kinds of problems they aim to solve. First I'll explain the problems, then I'll introduce the class hierarchies and the restricted class hierarchies step by step.
We'll take a simple example of an online delivery service where we use three possible states Preparing
, Dispatched
and Delivered
to display the current status of an online order.
Problems
Tagged class
Here we use a single class for all the states. Enums are used as type markers. They are used for tagging the states Preparing
, Dispatched
and Delivered
:
class DeliveryStatus(
val type: Type,
val trackingId: String? = null,
val receiversName: String? = null) {
enum class Type { PREPARING, DISPATCHED, DELIVERED }
}
The following function checks the state of the currently passed object with the help of enums and displays the respective status:
fun displayStatus(state: DeliveryStatus) = when (state.type) {
PREPARING -> print("Preparing for dispatch")
DISPATCHED -> print("Dispatched. Tracking ID: ${state.trackingId ?: "unavailable"}")
DELIVERED -> print("Delivered. Receiver's name: ${state.receiversName ?: "unavailable"}")
}
As you can see, we are able to display the different states properly. We also get to use exhaustive when
expression, thanks to enums. But there are various problems with this pattern:
Multiple responsibilities
The class DeliveryStatus
has multiple responsibilities of representing different states. So it can grow bigger, if we add more functions and properties for different states.
More properties than needed
An object has more properties than it actually needs in a particular state. For example, in the function above, we don't need any property for representing the Preparing
state. The trackingId
property is used only for the Dispatched
state and the receiversName
property is concerned only with the Delivered
state. The same is true for functions. I haven't shown functions associated with states to keep the example small.
No guarantee of consistency
Since these unused properties can be set from unrelated states, it's hard to guarantee the consistency of a particular state. For example, one can set the receiversName
property on the Preparing
state. In that case, the Preparing
will be an illegal state, because we can't have a receiver's name for the shipment that hasn't been delivered yet.
Need to handle null
values
Since not all properties are used for all states, we have to keep the properties nullable. This means we also need to check for the nullability. In the displayStatus()
function we check the nullability using the ?:
(elvis) operator and show unavailable
, if that property is null
. This complicates our code and reduces readability. Also, due to the possibility of a nullable value, the guarantee for consistency is reduced further, because the null
value of receiversName
in Delivered
is an illegal state.
Introducing Class Hierarchies
Unrestricted class hierarchy: abstract class
Instead of managing all the states in a single class, we separate the states in different classes. We create a class hierarchy from an abstract class
so that we can use polymorphism in our displayStatus()
function:
abstract class DeliveryStatus
object Preparing : DeliveryStatus()
class Dispatched(val trackingId: String) : DeliveryStatus()
class Delivered(val receiversName: String) : DeliveryStatus()
The trackingId
is now only associated with the Dispatched
state and receiversName
is only associated with the Delivered
state. This solves the problems of multiple responsibilities, unused properties, lack of state consistency and null values.
Our displayStatus()
function now looks like the following:
fun displayStatus(state: DeliveryStatus) = when (state) {
is Preparing -> print("Preparing for dispatch")
is Dispatched -> print("Dispatched. Tracking ID: ${state.trackingId}")
is Delivered -> print("Delivered. Received by ${state.receiversName}")
else -> throw IllegalStateException("Unexpected state passed to the function.")
}
Since we got rid of null
values, we can be sure that our properties will always have some values. So now we don't need to check for null
values using the ?:
(elvis) operator. This improves code readability.
So we solved all the problems mentioned in the tagged class section by introducing a class hierarchy. But the unrestricted class hierarchies have the following shortcomings:
Unrestricted Polymorphism
By unrestricted polymorphism I mean that our function displayStatus()
can be passed a value of unlimited number of subclasses of the DeliveryStatus
. This means we have to take care of the unexpected states in displayStatus()
. For this, we throw an exception.
Need for the else
branch
Due to unrestricted polymorphism, we need an else
branch to decide what to do when an unexpected state is passed. If we use some default state instead of throwing an exception and then forget to take care of any newly added subclass, then that default state will be displayed instead of the state of the newly created subclass.
No exhaustive when
expression
Since the subclasses of an abstract class
can exist in different packages and compilation units, the compiler doesn't know all the possible subclasses of the abstract class
. So it won't flag an error at compile time, if we forget to take care of any newly created subclasses in the when
expression. In that case, only throwing an exception can help us. Unfortunately, we'll know about the newly created state only after the program crashes at runtime.
Sealed Classes to the Rescue
Restricted class hierarchy: sealed class
Using the sealed
modifier on a class
does two things:
- It makes that class an
abstract class
. Since Kotlin 1.5, you can use asealed interface
too. - It makes it impossible to extend that class outside of that file. Since Kotlin 1.5 the same file restriction has been removed. Now the class can be extended in other files too but they need to be in the same compilation unit and in the same package as the
sealed
type.
sealed class DeliveryStatus
object Preparing : DeliveryStatus()
class Dispatched(val trackingId: String) : DeliveryStatus()
class Delivered(val receiversName: String) : DeliveryStatus()
Our displayStatus()
function now looks cleaner:
fun displayStatus(state: DeliveryStatus) = when (state) {
is Preparing -> print("Preparing for Dispatch")
is Dispatched -> print("Dispatched. Tracking ID: ${state.trackingId}")
is Delivered -> print("Delivered. Received by ${state.receiversName}")
}
Sealed classes offer the following advantages:
Restricted Polymorphism
By passing an object of a sealed class
to a function, you are also sealing that function, in a sense. For example, now our displayStatus()
function is sealed to the limited forms of the state
object, that is, it will either take Preparing
, Dispatched
or Delivered
. Earlier it was able to take any subclass of DeliveryStatus
. The sealed
modifier has put a limit on polymorphism. As a result, we don't need to throw an exception from the displayStatus()
function.
No need for the else
branch
Due to restricted polymorphism, we don't need to worry about other possible subclasses of DeliveryStatus
and throw an exception when our function receives an unexpected type. As a result, we don't need an else
branch in the when
expression.
Exhaustive when
expression
Just like all the possible values of an enum class
are contained inside the same class, all the possible subtypes of a sealed class
are contained inside the same package and the same compilation unit. So, the compiler knows all the possible subclasses of this sealed
class. This helps the compiler to make sure that we have covered(exhausted) all the possible subtypes in the when
expression. And when we add a new subclass and forget to cover it in the when
expression, it flags an error at compile time.
Note that in the latest Kotlin versions, your when
is exhaustive for the when
expressions as well the when
statements.
Why in the same file?
The same file restriction has been removed since Kotlin 1.5. Now you can define the subclasses of the sealed class
in different files but the files need to be in the same package and the same compilation unit. Before 1.5, the reason that all the subclasses of a sealed class
needed to be in the same file was that it had to be compiled together with all of its subclasses for it to have a closed set of types. If the subclasses were allowed in other files, the build tools like Gradle would have to keep track of the relations of files and this would affect the performance of incremental compilation.
IDE feature: Add remaining branches
When you just type when (status) { }
and press Alt + Enter, Enter, the IDE automatically generates all the possible branches for you like the following:
when (state) {
is Preparing -> TODO()
is Dispatched -> TODO()
is Delivered -> TODO()
}
In our small example there are just three branches but in a real project you could have hundreds of branches. So you save the effort of manually looking up which subclasses you have defined in different files and writing them in the when
expression one by one in another file. Just use this IDE feature. Only the sealed
modifier enables this.
That's it! Hope this helps you understand the essence of sealed classes.
If you've ever used an enum
with an abstract method
just so that you could do something like this:
public enum ResultTypes implements ResultServiceHolder {
RESULT_TYPE_ONE {
@Override
public ResultOneService getService() {
return serviceInitializer.getResultOneService();
}
},
RESULT_TYPE_TWO {
@Override
public ResultTwoService getService() {
return serviceInitializer.getResultTwoService();
}
},
RESULT_TYPE_THREE {
@Override
public ResultThreeService getService() {
return serviceInitializer.getResultThreeService();
}
};
When in reality what you wanted is this:
val service = when(resultType) {
RESULT_TYPE_ONE -> resultOneService,
RESULT_TYPE_TWO -> resultTwoService,
RESULT_TYPE_THREE -> resultThreeService
}
And you only made it an enum abstract method to receive compile time guarantee that you always handle this assignment in case a new enum type is added; then you'll love sealed classes because sealed classes used in assignments like that when
statement receive a "when should be exhaustive" compilation error which forces you to handle all cases instead of accidentally only some of them.
So now you cannot end up with something like:
switch(...) {
case ...:
...
default:
throw new IllegalArgumentException("Unknown type: " + enum.name());
}
Also, enums cannot extend classes, only interfaces; while sealed classes can inherit fields from a base class. You can also create multiple instances of them (and you can technically use object
if you need the subclass of the sealed class to be a singleton).
Say you have a domain (your pets) where you know there is a definite enumeration (count) of types. For example, you have two and only two pets (which you will model with a class called MyPet
). Meowsi is your cat and Fido is your dog.
Compare the following two implementations of that contrived example:
sealed class MyPet
class Meowsi : MyPet()
class Fido : MyPet()
Because you have used sealed classes, when you need to perform an action depending on the type of pet, then the possibilities of MyPet
are exhausted in two and you can ascertain that the MyPet
instance will be exactly one of the two options:
fun feed(myPet: MyPet): String {
return when(myPet) {
is Meowsi -> "Giving cat food to Meowsi!"
is Fido -> "Giving dog biscuit to Fido!"
}
}
If you don't use sealed classes, the possibilities are not exhausted in two and you need to include an else
statement:
open class MyPet
class Meowsi : MyPet()
class Fido : MyPet()
fun feed(myPet: MyPet): String {
return when(myPet) {
is Mewosi -> "Giving cat food to Meowsi!"
is Fido -> "Giving dog biscuit to Fido!"
else -> "Giving food to someone else!" //else statement required or compiler error here
}
}
In other words, without sealed classes there is not exhaustion (complete coverage) of possibility.
Note that you could achieve exhaustion of possiblity with Java enum
however these are not fully-fledged classes. For example, enum
cannot be subclasses of another class, only implement an interface (thanks EpicPandaForce).
What is the use case for complete exhaustion of possibilities? To give an analogy, imagine you are on a tight budget and your feed is very precious and you want to ensure you don't end up feeding extra pets that are not part of your household.
Without the sealed
class, someone else in your home/application could define a new MyPet
:
class TweetiePie : MyPet() //a bird
And this unwanted pet would be fed by your feed
method as it is included in the else
statement:
else -> "Giving food to someone else!" //feeds any other subclass of MyPet including TweetiePie!
Likewise, in your program exhaustion of possibility is desirable because it reduces the number of states your application can be in and reduces the possibility of bugs occurring where you have a possible state where behaviour is poorly defined.
Hence the need for sealed
classes.
Mandatory else
Note that you only get the mandatory else
statement if when
is used as an expression. As per the docs:
If [
when
] is used as an expression, the value of the satisfied branch becomes the value of the overall expression [... and] theelse
branch is mandatory, unless the compiler can prove that all possible cases are covered with branch conditions
This means you won't get the benefit of sealed classes for something like this):
fun feed(myPet: MyPet): Unit {
when(myPet) {
is Meowsi -> println("Giving cat food to Meowsi!") // not an expression so we can forget about Fido
}
}
To get exhaustion for this scenario, you would need to turn the statement into an expression with return type.
Some have suggested an extension function like this would help:
val <T> T.exhaustive: T
get() = this
Then you can do:
fun feed(myPet: MyPet): Unit {
when(myPet) {
is Meowsi -> println("Giving cat food to Meowsi!")
}.exhaustive // compiler error because we forgot about Fido
}
Others have suggested that an extension function pollutes the namespace and other workarounds (like compiler plugins) are required.
See here for more about this problem.