How and when to use Sealed classes in Kotlin and Android

Yaroslav T
4 min readJul 16, 2023

--

In this short article, I would like to briefly explain what sealed classes are in Kotlin, when and how to use them. You can use this article as a cheat sheet for a person who has just started their journey in Kotlin and isn’t familiar with sealed classes.

Definition

Sealed classes/sealed interfaces represent a type that is limited to specific values and only those values. Inheritors of the sealed class might have different params but still belong to the same type.

You can find a comprehensive and detailed definition in the official doc, but let me show you a quick example instead:

sealed class Time {
object Day : Time()
object Night : Time()
}

There are no other possible values for time, it can only be either Day or Night.

For simplicity, I will refer to sealed classes in this article, but the same logic applies to sealed interfaces as well.

When to use

Use Sealed classes when you know all the possible outputs.

fun loadNicknameFromServer(): Flow<Result>

sealed class Result {
object Loading : Result()
data class Error(val t: Throwable) : Result()
data class Success(val nickname: String) : Result()
}

When you’re loading a value from the internet, the potential outputs are already known:

  1. When you start a request, the output is Loading (you can represent it on the UI with a progress bar)
  2. If the request fails, your output is Error, so you can show a Toast with an error message and a button “Try again”.
  3. When the request is successful and the data is loaded, your output is Success and you can display the value on the UI

How to use

To define a sealed class or sealed interface with all possible inheritors, the syntax for both is quite similar:

sealed class EventSource {
object Push : EventSource()
object Email : EventSource()
}

sealed interface EventSource {
object Push : EventSource
object Email : EventSource
}

The main advantage of sealed classes/interfaces is the ability to use an exhaustive when expression to have compile-time checks that you have handled every possible outcome. Additionally, in each when branch, you don't need to cast the value as it is done automatically:

sealed class EventSource {
data class Push(val message: String) : EventSource()
data class Email(val sender: String) : EventSource()
}

fun getEventSource(): EventSource

val eventSource = getEventSource()
when (eventSource) {
is EventSource.Push -> println("Push has message = ${eventSource.message}")
is EventSource.Email -> println("Email is from = ${eventSource.sender}")
}

If you don’t need to handle some of the sealed class inheritors, you can use else in the when expression, but I strongly recommend against doing that. By doing so, you might miss handling new variants of your sealed class in the future. Instead, use else with a comma and an empty code block to explicitly state that you have considered all possible cases:

sealed class EventSource {
object Push : EventSource()
object Email : EventSource()
object PhoneCall : EventSource()
}

when (eventSource) {
EventSource.Push -> println("Event is from Push")
EventSource.Email,
EventSource.PhoneCall -> Unit // Don't handle other sources except Push
}

Difference between Sealed class and Enum class

All enum constants must override the same arguments, so if you want to different values, you have to write ugly boilerplate code. Look at the example with Push and Email event sources, sealed class looks much cleaner:

sealed class EventSource {
data class Push(val message: String) : EventSource()
data class Email(val sender: String) : EventSource()
}

enum class EventSource(val message: String?, val sender: String?) {
Push(message = "text", sender = null),
Email(message = null, sender = "John")
}

Differences between Sealed class and Sealed interface

In my opinion, the only useful difference are private variables in Sealed classes. Imagine you want to categorize the EventSource based on priority, you can define it like this

sealed class EventSource(private val priority: Int) {
object Push : EventSource(priority = 1)
object Email : EventSource(priority = 2)
}

sealed interface EventSource {
val priority: Int
object Push : EventSource {
override val priority: Int = 1
}
object Email : EventSource{
override val priority: Int = 2
}
}

In a sealed interface, you cannot make the priority field private, so other people can access it. But sealed classes allow you to encapsulate any field you want.

Usage with Generics

Let’s revisit our example with the Result sealed class. It wasn’t flexible because its inheritor was limited to the exact type String:

data class Success(val nickname: String) : Result()

To make it flexible we can use Kotlin Generics:

fun loadNicknameFromServer(): Flow<Result<Nickname>>

data class Nickname(val nickname: String, val dateOfAssign: Long)

sealed interface Result<out T> {
object Loading : Result<Nothing>
data class Error(val t: Throwable) : Result<Nothing>
data class Success<T>(val content: T) : Result<T>
}

Conclusion

Sealed classes is not a silver bullet. If you have to handle every possible outcome and would like this code to be clean and easy to maintain, use Sealed classes. Otherwise, a data class with many fields or a regular interface with inheritors could be enough.

--

--

Yaroslav T
Yaroslav T

Written by Yaroslav T

Android developer at Revolut

No responses yet