Kotlin is a modern, open source language for developing multiplatform mobile applications. It is a very easy and friendly language to learn that is also simple to maintain and debug. Kotlin employs the features of object-oriented programming and functional programming concepts, which allows Kotlin to focus on simplifying its code while maintaining code safety.
Like any object-oriented programming language, Kotlin employs features such as classes and objects in its codebase. Think of a class as set design and the object as the implementation of that design. In simple terms, an object implements the actual class blueprint. A class defines all the properties and the behavior of an object.
The concept of classes goes wide and deep. Kotlin even offers different higher-level concepts to write classes. For example, Kotlin has sealed classes, data classes, abstract classes, and enum classes that let you dive deeper and explore that class’s options.
In this guide, we will learn the concept of sealed classes and how to use them in Kotlin.
when
expressionSealed classes represent a restricted class hierarchy. This allows you to define subclasses within the scope of the parent function, allowing you to represent hierarchies. In this case, the child or subclass can be of any type, a data class, an object, a regular class, or another sealed class.
Sealed classes can restrict which other classes are able to implement them. This gives you the power to represent a limited set of possibilities within your application by having restricted values in your project.
A sealed class is an extension of the enum class. Enum classes (also known as enumerated types) only allow a single instance of each value. We generally use an enum to store the same type of constant values.
Enum classes are also restricted hierarchies; however, each enum constant exists only as a single instance, whereas a subclass of a sealed class can have multiple instances.
Enums and sealed classes are commonly used to represent a type that has several values from a set of possibilities. To understand how the sealed classes work, let’s have a quick glance at how the enum classes are used to model types that represent a limited number of different values.
You can create an enum class using the keyword enum
. Enum is a class type for storing a list of constants representing a set of types of a single object. Let’s say you have a restaurant and you only have three items on the food menu: PIZZA, BURGER, and CHICKEN. A user may order another food item, such as a SANDWICH, but your restaurant doesn’t have this food on the menu.
Thus, when customers try to order a SANDWICH, they should be restricted because you don’t have it on the menu. There is a type safety that should be added to prevent customers from ordering non-existing food items.
You can use an enum class to add type restriction, and only the PIZZA, BURGER, and CHICKEN are allowed in your application, and no user can order any random item like SANDWICH. For example, we can store constant values for each menu type within this enum class of Menu
:
enum class Menu{ PIZZA, BURGER, CHICKEN }
The Menu
class contains the types PIZZA, BURGER, and CHICKEN. All these constants inside the Menu
enum are objects. We can get all their values using this Menu
enum. These constants can be printed, initialized, and traversed through. In this case, this class will have a type safety added to it. You cannot use other constants to assign a value.
This means your application will have exact input that accepts these three items from the user. If the user tries to enter any other item that is not relevant to your data set, it should be an error.
You can assign some value to these enum objects. Each state can be assigned to constructor parameters as shown below:
enum class Menu (val quantity: Int){ PIZZA (6), BURGER (4), CHICKEN (8)
This adds a parameter to these three states. However, it is impossible to have different states for a specific constant. For example, if you have to add something like a size to PIZZA
, it is impossible to use an enums class.
With enums, a single state applies to each constant. However, this issue can be solved using sealed classes. Sealed classes give you the flexibility to have different types of subclasses that can contain the different states for different constants.
Below is how we can hold the three menu states using a sealed class:
sealed class Menu{ class PIZZA:Menu() class BURGER: Menu() class CHICKEN:Menu() }
Like we said earlier, a sealed class can have subclasses. These subclasses can be of any type; a data class, an object, a regular class, or another sealed class.
In this example, the states have constructor parameters. Let’s make these subclasses of type data, then add parameters based on each state as shown below:
sealed class Menu{ data class PIZZA(val name: String, val size:String, val quantity:Int):Menu() data class BURGER(val quantity:Int, val size:String): Menu() data class CHICKEN(val quantity:Int, val pieces:String):Menu() }
Each subclass extends to the parent class Menu
. In this example, each item has different parameters. Although PIZZA, BURGER, and CHICKEN extend the Menu
sealed class, their parameters differ. This was not possible with an enum, as we’ve seen in the previous step. Sealed classes give you the flexibility of having different types of subclasses and contain the state. This means the heir of the sealed class can have as many as any number of instances and can store states, but the enum class cannot.
Sealed classes also offer a restricted number of hierarchies. This means if you have a different class defined in another file in your project, you cannot extend the class Menu
. It provides a restricted number of hierarchies, making it private. Therefore, all inheritors of a sealed class must be declared in the same package as the sealed class itself.
In this case, they must be defined within the scope of Menu
. However, you can still define these subclasses outside of it within the same file. For example:
sealed class Menu{ data class PIZZA(val name: String, val size:String, val quantity:Int):Menu() data class BURGER(val quantity:Int, val size:String): Menu() } data class CHICKEN(val quantity:Int, val pieces:String):Menu()
when
expressionIn our restaurant menu example, we are representing a limited set of possibilities. A menu can either be PIZZA, BURGER, or CHICKEN. Otherwise, it is not available on the menu. The application has to exhaust all these possibilities within the application.
Kotlin uses the when
expression as the replacement for the if
, else if
, and else
or switch
case expression used in Java. These expressions use the else
branch to make sure all the possibilities have been handled.
An expression must always return a value. Therefore, all cases must be present within the expression. Typically, you’re required to include an else
clause to catch anything that might be missed. The else
clause makes the expression exhaustive, ensuring any possible cases have been implemented. However, when using sealed or enum classes, the else
clause is not needed. This is because of their restricted class hierarchy that ensures all possible cases are known during compile time.
Enums represent a limited set of types/possibilities. In this case, every type must be considered and executed exhaustively. This case can be easily implemented using the when
expression as shown below:
enum class Menu (val quantity: Int) { PIZZA(6), BURGER(4), CHICKEN(8) } fun SelectedMenu(menu:Menu) { return when (menu) { Menu.PIZZA -> println("You have ordered ${menu.name} ${menu.quantity} pieces") Menu.BURGER -> println("You have ordered ${menu.name} ${menu.quantity} pieces") } }
In the above example, we haven’t added all branches. The when the expression will throw an error. This is because the when
expression must be exhaustive. Thus, you need to add the necessary PIZZA
, BURGER
, CHICKEN
, or else
branch instead.
When using the restricted hierarchies, the IDE already knows the branches you need to implement. It will even highlight an error message that indicates what your when expression is missing:
'when' expression must be exhaustive, add the necessary 'CHICKEN' branch or 'else' branch instead.
You can even use a quick fix to add any remaining branches.
This ensures that all the limited possibilities that the enum and sealed classes have must be implemented. In this case, we don’t need an ‘else’ branch. Adding the Menu.CHICKEN -> println("You have ordered ${menu.name} ${menu.quantity} pieces")
will make the expression complete. You can log this inside the main()
folder:
fun main() { SelectedMenu(Menu.PIZZA) SelectedMenu(Menu.BURGER) SelectedMenu(Menu.CHICKEN) }
Output:
You have ordered PIZZA: 6 pieces You have ordered BURGER: 4 pieces You have ordered CHICKEN: 8 pieces
The when
expression ensures that you keep track of possible options within your enums, which is great. However, as we discussed earlier, enums lack diversification in representing constants. Each enum constant exists only as a single instance. In such a case, sealed classes will come in handy:
sealed class Menu{ data class PIZZA(val quantity:Int, val size:String, val name: String):Menu() data class BURGER(val quantity:Int, val size:String): Menu() data class CHICKEN(val name:String, val pieces:Int):Menu() object NotInTheMunu : Menu() } fun SelectMenu(menu: Menu) { when (menu) { is Menu.BURGER -> println("${menu.quantity} ${menu.size} BURGER") is Menu.CHICKEN -> println("${menu.pieces} CHICKEN ${menu.name}") is Menu.PIZZA -> println("${menu.quantity} ${menu.size} ${menu.name} PIZZA") Menu.NotInTheMunu -> println("Not in the menu") // else clause is not required as we've covered all the cases } }
Each of the above menus have values associated with them that can change during run time. With Kotlin’s smart casting system, we can pull these values directly out of the Menu
argument. We can do this without casting them as long as the return type to the when expression is the same:
private fun SelectedMenu(){ val menuItem = readLine() val menu = when { menuItem!!.contains("BURGER", true) -> Menu.BURGER(10, "king size") menuItem.contains("CHICKEN", true) -> Menu.CHICKEN("wings", 4) menuItem.contains("PIZZA", true) -> Menu.PIZZA( 1, "medium","Pepperoni") else -> Menu.NotInTheMunu } SelectMenu(menu as Menu) SelectedMenu() }
Run the SelectedMenu()
inside the main function to execute the above value:
fun main() { println("Select Menu") SelectedMenu() }
Then add a menu item inside the interactive IDE command line and watch for results.
Sealed interfaces were introduced in Kotlin 1.5. Specifically, the sealed
modifier works on interfaces just as it does on classes. Plus, a sealed interface is also a valid Kotlin interface. This means that not only are all implementations of a sealed interface known at compile time, but it also has all the typical characteristics of a Kotlin interface.
You can define a sealed
interface in Kotlin as follows:
sealed interface Menu
Then, you can use it as below:
sealed interface Menu { data class PIZZA(val name: String, val size: String, val quantity: Int): Menu data class BURGER(val quantity: Int, val size: String): Menu data class CHICKEN(val quantity: Int, val pieces: String): Menu }
Just like what happens for sealed classes, you can rely on sealed interfaces to write exhaustive when
expressions:
fun draw(menu: Menu) = when (menu) { is Menu.PIZZA -> // ... is Menu.BURGER -> // ... is Menu.CHICKEN -> // ... }
Note that the else branch is not required since all possible implementations are already covered by the when
statement.
As explained earlier, the sealed
modifier acts on classes and interfaces in the same way. So, you may wonder why you should use sealed interfaces instead of sealed classes. There are at least two good reasons. Let’s dig into them.
Sealed interfaces act like standard Koltin interfaces. Considering that enum classes in Kotlin can implement interfaces, this also means that enums can implement sealed interfaces. Learn more about Kotlin enums here.
In contrast, Kotlin enum classes cannot derive from a class. Therefore, Kotlin enums cannot derive from a sealed class. So, this means that the following code is allowed in Kotlin:
sealed interface A enum class B : A
This will compile with no errors.
On the contrary, the snippet below is illegal in Kotlin:
sealed class A enum class B : A()
This will lead to the following error:
Enum class cannot inherit from classes
Just like what happens for standard interfaces, a Kotlin class can implement more than one sealed interface. This means that sealed interfaces enable the definition of more flexible restricted class hierarchies.
Let’s consider the example below:
// to define two new subhierarchies sealed interface DinnerMenu sealed interface LunchMenu sealed class Menu { // resticting the Menu hieararchy to LaunchMenu and DinnerMenu data class PIZZA(val name: String, val size: String, val quantity: Int): Menu(), DinnerMenu data class BURGER(val quantity: Int, val size: String): Menu(), LunchMenu data class CHICKEN(val quantity: Int, val pieces: String): Menu(), LunchMenu }
By adding two sealed interfaces, you can restrict the Menu
hierarchy to two new subhierarchies. In detail, the LunchMenu
hierarchy will contain both Menu.BURGER
and Menu.CHICKEN
. While the DinnerMenu
hierarchy will contain only Menu.PIZZA
.
You can then use these new subhierarchies as follows:
fun logMenu(menu: Menu) { when (menu) { is Menu.PIZZA -> print("pizza") is Menu.BURGER -> print("burger") is Menu.CHICKEN -> print("chicken") } } fun logLunchMenu(menu: LunchMenu) { when (menu) { is Menu.BURGER -> print("burger") is Menu.CHICKEN -> print("chicken") } } fun logDinnerMenu(menu: DinnerMenu) { when (menu) { is Menu.PIZZA -> print("pizza") } }
This example shows that the original Menu
hierarchy still works the same way. What changes is that there are now also the LunchMenu
and DinnerMenu
subhierarchies.
Let’s learn how we can use Kotlin’s sealed class to manage states. This case can be implemented using an enum class or an abstract class, but we’ll take a closer look at why sealed classes outperform enums and abstract classes in this case.
The enum class allows you to limit an object’s value to a certain set of values. This is how we can represent these states in an enum:
enum class ResultState{ LOADING, SUCCESS, ERROR, }
To iterate through these states, we will use the when
expression, which we described in the previous step. Then, we add all the possible branches/states:
fun UIResult(resultState: ResultState) = when(resultState){ ResultState.LOADING -> println("The Data is loading...Please wait") ResultState.SUCCESS -> println("Data has been loaded successfully") ResultState.ERROR -> println("An Error encountered while loading data") }
Now we can print out these states inside the main function:
fun main(){ UIResult(ResultState.LOADING) UIResult(ResultState.SUCCESS) }
However, this case fits best when used to dynamically load data from the internet. You can use different architecture patterns such as the repository pattern, Redux, MVI ( Model-View-Intent), and MVVM (Model-View-View-Model). In this case, let’s try to create the repository pattern to represent a single instance in the entire application. This will try to implement and fetch a data instance as it would be represented in a typical data fetching API/database:
object MainRepository{ private var data:String? = null fun loading(): ResultState { val loadData = ResultState.LOADING data = "" return loadData } fun display (): ResultState { val displayData = ResultState.SUCCESS data = null return displayData } fun error(): ResultState { return ResultState.ERROR } }
Finally, execute the above data loading states inside the main function:
fun main(){ UIResult(MainRepository.loading()) UIResult(MainRepository.display()) UIResult(MainRepository.error()) }
We have used enum restricted hierarchies to manage the execution of these states. However, loading this data requires you to return different constants for each state. In this case, we need to have an exception that allows us to know which state of error we are in. On one hand, loading this data requires the SUCCESS state to return the type of data being fetched. This can be an array of data, string, or any other data type. This means each state is different.
This case cannot be solved using enums. Each state has different parameters executed.
We can represent these states using abstract classes in order to showcase the parameters that each executes.
The following code shows how to manage the state using an abstract class in Kotlin:
abstract class ResultState{ object LOADING: ResultState() data class SUCCESS(val viewData: Array<Any>): ResultState() data class ERROR(val errormessage: Throwable?): ResultState() } fun UIResult(resultState: ResultState) = when(resultState){ is ResultState.LOADING -> println("The Data is loading...Please wait") is ResultState.SUCCESS -> println("Data has been loaded successfully") is ResultState.ERROR -> println("An Error encountered while loading data") }
Note when using the abstract, it requires you to add an else
branch:
fun UIResult(resultState: ResultState) = when(resultState){ is ResultState.LOADING -> println("The Data is loading...Please wait") is ResultState.SUCCESS -> println("Data has been loaded successfully") is ResultState.ERROR -> println("An Error encountered while loading data") else -> println("Unknown error") }
Now, we mimic the data that we want to fetch, like so:
object MainRepository{ private var data:String? = null fun loading(): ResultState { val loadData = ResultState.LOADING data = "" return loadData } fun display (): ResultState { val displayData = ResultState.SUCCESS(arrayOf(String)) data = null return displayData } fun error(): ResultState { return ResultState.ERROR(null) } }
The key point to note here is that you will need to add an else
case within your when
expression. However, this case is error-prone. When using the abstract class, the IDE is not aware when all branches are exhaustively exploited.
Let’s see what happens if you decide to add an additional state, for example object InProgress: ResultState()
, as showcased below:
abstract class ResultState{ object LOADING: ResultState() data class SUCCESS(val viewData: Array<Any>): ResultState() data class ERROR(val errormessage: Throwable?): ResultState() object InProgress: ResultState() } fun UIResult(resultState: ResultState) = when(resultState){ is ResultState.LOADING -> println("The Data is loading...Please wait") is ResultState.SUCCESS -> println("Data has been loaded successfully") is ResultState.ERROR -> println("An Error encountered while loading data") else -> println("Unknown error") }
In this case, the compiler does not indicate that you should add the ResultState
logic for the InProgress
into our when
statement. Instead, during runtime, it’ll default to the else case, which could cause bugs.
On the other hand, the abstract will lose the restricted hierarchy that the enum is trying to implement.
This forces you to use the sealed class to ensure that all branches are exhaustively executed while ensuring the concept of restricted classes is kept all through the application.
Sealed classes allow you to limit the types of objects that may be created, allowing you to write more comprehensive and predictable code. For example, take the ERROR state. In this case, an error can have many instances, such as ServerError
, InternalError
, or UnknownError
.
Below is how we can represent them as a sealed class:
sealed class ResultState{ object LOADING: ResultState() data class SUCCESS(val viewData: Array<Any>): ResultState() sealed class ERROR: ResultState() { class InternalError(val errormessage: java.lang.InternalError): ERROR() class ServerError( val errormessage: java.rmi.ServerError?): ERROR() class UnknownError(val errormessage: java.lang.UnknownError): ERROR() } }
Additionally, when using sealed classes, you are forced to add exhaustive implementation before compile time; otherwise, you’ll receive an error:
fun UIResult(resultState: ResultState) = when(resultState){ is ResultState.LOADING -> println("The Data is loading...Please wait") is ResultState.SUCCESS -> println("Data has been loaded successfully") is ResultState.ERROR.InternalError -> println("Internet error occurred") is ResultState.ERROR.UnknownError -> println("Query occurred") is ResultState.ERROR.ServerError -> println("Server occurred") }
Now, we mimic the data that we want to fetch:
object MainRepository{ private var data:String? = null fun loading(): ResultState { val loadData = ResultState.LOADING data = "" return loadData } fun display (): ResultState { val displayData = ResultState.SUCCESS(arrayOf(String)) data = null return displayData } fun serverError(): ResultState.ERROR.ServerError{ return ResultState.ERROR.ServerError(null) } fun internalError(): ResultState.ERROR.InternalError{ val errormessage = InternalError() return ResultState.ERROR.InternalError(errormessage) } fun unknownError (): ResultState.ERROR.UnknownError { val errormessage = UnknownError() return ResultState.ERROR.UnknownError(errormessage) } }
Finally, execute the above data loading states inside the main function:
fun main(){ UIResult(MainRepository.loading()) UIResult(MainRepository.display()) UIResult(MainRepository.unknownError()) UIResult(MainRepository.serverError()) UIResult(MainRepository.internalError()) }
In this article, we learned about how Kotlin’s sealed classes work, and why they might be a better choice that enum or abstract classes. We also reviewed state management in Kotlin using sealed classes. Hopefully, you feel confident enough to use sealed classes in your next Kotlin project!
If you have any questions, feel free to leave them in the comments section below.
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.