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:

  1. It makes that class an abstract class. Since Kotlin 1.5, you can use a sealed interface too.
  2. 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] the else 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.

Tags:

Kotlin