Kotlin Sealed Classes
Introduction
Sealed classes are a powerful feature in Kotlin that represent restricted class hierarchies. In these hierarchies, a value can have a type from a limited set of types, and no other type. They're somewhat similar to enum classes but more flexible, as each subclass can have multiple instances along with their own state.
Sealed classes are particularly useful when you're working with states that can only be one of several well-defined types, making them perfect for representing limited hierarchies like network responses, UI states, or result types.
What Are Sealed Classes?
A sealed class is a class that restricts the possibility of inheritance. All direct subclasses of a sealed class must be declared in the same file as the sealed class itself. This constraint allows the compiler to verify that all possible subclasses are known at compile time, enabling exhaustive checks in when
expressions.
Basic Syntax
sealed class Result {
data class Success(val data: Any) : Result()
data class Error(val message: String, val exception: Exception? = null) : Result()
object Loading : Result()
object Empty : Result()
}
Key Features of Sealed Classes
1. Restricted Hierarchy
All subclasses of a sealed class must be defined in the same source file where the sealed class is defined. This restriction ensures that the compiler knows all possible types at compile time.
2. Abstract by Default
Sealed classes are abstract by default. They cannot be instantiated directly, only through their subclasses.
sealed class Shape {
// Cannot create instance of Shape directly
abstract fun area(): Double
}
class Circle(val radius: Double) : Shape() {
override fun area() = Math.PI * radius * radius
}
class Rectangle(val width: Double, val height: Double) : Shape() {
override fun area() = width * height
}
class Triangle(val base: Double, val height: Double) : Shape() {
override fun area() = 0.5 * base * height
}
3. Exhaustive When Statements
One of the most powerful features of sealed classes is that they enable exhaustive checking in when
expressions. When you use a sealed class in a when
statement, the compiler can verify that all cases are covered:
fun getShapeInfo(shape: Shape): String {
return when (shape) {
is Circle -> "Circle with radius ${shape.radius} and area ${shape.area()}"
is Rectangle -> "Rectangle with width ${shape.width}, height ${shape.height} and area ${shape.area()}"
is Triangle -> "Triangle with base ${shape.base}, height ${shape.height} and area ${shape.area()}"
// No 'else' branch needed - compiler knows all possible subclasses
}
}
4. Combining with Data Classes and Objects
Sealed classes work particularly well with data classes and objects, allowing you to create a rich type hierarchy:
sealed class ApiResponse<T> {
data class Success<T>(val data: T) : ApiResponse<T>()
data class Error<T>(val code: Int, val message: String) : ApiResponse<T>()
data class Loading<T>(val progress: Float? = null) : ApiResponse<T>()
object Empty : ApiResponse<Nothing>()
}
fun <T> handleResponse(response: ApiResponse<T>) {
when (response) {
is ApiResponse.Success -> println("Data received: ${response.data}")
is ApiResponse.Error -> println("Error: ${response.code} - ${response.message}")
is ApiResponse.Loading -> {
val progress = response.progress
if (progress != null) {
println("Loading... ${progress * 100}%")
} else {
println("Loading...")
}
}
ApiResponse.Empty -> println("No data available")
}
}
Sealed Classes vs Enums
While sealed classes and enums might seem similar, they have different purposes and capabilities:
Feature | Sealed Classes | Enums |
---|---|---|
State per instance | Can have different state | Each instance has the same properties |
Multiple instances | Each subclass can have multiple instances | Fixed number of instances |
Constructor parameters | Each subclass can have its own parameters | All instances share the same parameter structure |
Flexibility | More flexible | More constrained |
Use case | Complex hierarchies with different behaviors | Simple set of related constants |
When to Use Sealed Classes
Sealed classes are ideal in the following situations:
- Representing a limited set of possible states: Like UI states (loading, success, error)
- Result wrappers: For handling success/failure scenarios
- Expression trees: When building domain-specific languages or parsers
- Event hierarchies: For handling various types of events in your application
Practical Example: UI State Management
Let's see how sealed classes can be used to manage UI states in an application:
sealed class UiState {
object Initial : UiState()
object Loading : UiState()
data class Success(val data: List<Item>) : UiState()
data class Error(val message: String) : UiState()
}
class ItemsViewModel {
// Mutable state that the UI will observe
private val _state = MutableLiveData<UiState>(UiState.Initial)
val state: LiveData<UiState> = _state
fun loadItems() {
_state.value = UiState.Loading
// Simulate network request
try {
// Pretend we got data from somewhere
val items = fetchItems()
_state.value = if (items.isEmpty()) {
UiState.Error("No items found")
} else {
UiState.Success(items)
}
} catch (e: Exception) {
_state.value = UiState.Error("Failed to load items: ${e.message}")
}
}
private fun fetchItems(): List<Item> {
// Simulate network request
return listOf(Item("Item 1"), Item("Item 2"))
}
}
// In your UI
fun renderState(state: UiState) {
when (state) {
UiState.Initial -> showInitialUi()
UiState.Loading -> showLoadingIndicator()
is UiState.Success -> displayItems(state.data)
is UiState.Error -> showErrorMessage(state.message)
}
}
Another Practical Example: Parsing JSON
Sealed classes are great for handling different shapes of data that might come from external sources like JSON:
sealed class ConfigElement {
data class TextElement(val text: String, val style: TextStyle) : ConfigElement()
data class ImageElement(val url: String, val size: Size) : ConfigElement()
data class ButtonElement(val text: String, val action: String) : ConfigElement()
data class ContainerElement(val elements: List<ConfigElement>) : ConfigElement()
}
fun parseJsonToConfigElement(json: JsonElement): ConfigElement {
return when (json.getString("type")) {
"text" -> ConfigElement.TextElement(
json.getString("content"),
parseTextStyle(json.getObject("style"))
)
"image" -> ConfigElement.ImageElement(
json.getString("url"),
Size(json.getInt("width"), json.getInt("height"))
)
"button" -> ConfigElement.ButtonElement(
json.getString("label"),
json.getString("actionId")
)
"container" -> {
val childElements = json.getArray("children").map { parseJsonToConfigElement(it) }
ConfigElement.ContainerElement(childElements)
}
else -> throw IllegalArgumentException("Unknown element type: ${json.getString("type")}")
}
}
fun renderConfigElement(element: ConfigElement) {
when (element) {
is ConfigElement.TextElement -> renderText(element.text, element.style)
is ConfigElement.ImageElement -> renderImage(element.url, element.size)
is ConfigElement.ButtonElement -> renderButton(element.text, element.action)
is ConfigElement.ContainerElement -> {
// Render container and then recursively render children
element.elements.forEach { renderConfigElement(it) }
}
}
}
Common Patterns with Sealed Classes
The Result Pattern
One of the most common uses of sealed classes is representing operation results:
sealed class Result<out T> {
data class Success<out T>(val value: T) : Result<T>()
data class Failure(val error: Throwable) : Result<Nothing>()
}
fun divide(a: Int, b: Int): Result<Int> {
return if (b == 0) {
Result.Failure(IllegalArgumentException("Division by zero"))
} else {
Result.Success(a / b)
}
}
// Usage
fun main() {
val result = divide(10, 2)
when (result) {
is Result.Success -> println("Result: ${result.value}") // Prints: Result: 5
is Result.Failure -> println("Error: ${result.error.message}")
}
val errorResult = divide(10, 0)
when (errorResult) {
is Result.Success -> println("Result: ${errorResult.value}")
is Result.Failure -> println("Error: ${errorResult.error.message}") // Prints: Error: Division by zero
}
}
Nested Sealed Classes
You can also nest sealed classes to create more complex hierarchies:
sealed class NetworkEvent {
sealed class Connection : NetworkEvent() {
object Connected : Connection()
object Disconnected : Connection()
data class ConnectionError(val reason: String) : Connection()
}
sealed class Data : NetworkEvent() {
data class Received(val data: ByteArray) : Data()
data class Sent(val size: Int) : Data()
data class Error(val exception: Exception) : Data()
}
}
fun handleNetworkEvent(event: NetworkEvent) {
when (event) {
is NetworkEvent.Connection.Connected -> println("Connected to server")
is NetworkEvent.Connection.Disconnected -> println("Disconnected from server")
is NetworkEvent.Connection.ConnectionError -> println("Connection error: ${event.reason}")
is NetworkEvent.Data.Received -> println("Received ${event.data.size} bytes")
is NetworkEvent.Data.Sent -> println("Sent ${event.size} bytes")
is NetworkEvent.Data.Error -> println("Data error: ${event.exception.message}")
}
}
Limitations of Sealed Classes
While sealed classes are powerful, they have some limitations:
- All subclasses must be in the same file: This can lead to large files if you have many subclasses.
- No new subclasses can be added outside the file: This makes sealed classes less extensible than regular abstract classes.
- Increased boilerplate: Compared to enums, sealed classes require more code to define.
Summary
Sealed classes in Kotlin provide a powerful way to represent restricted class hierarchies. They combine the benefits of enums with the flexibility of class hierarchies, allowing each subclass to have its own state and behavior.
Key takeaways:
- Sealed classes restrict inheritance to a known set of subclasses
- They enable exhaustive checks in
when
expressions without needing anelse
branch - They work well with data classes and object declarations
- They're perfect for representing limited hierarchies like states, results, or events
- All subclasses must be defined in the same file as the sealed class
By using sealed classes in your Kotlin code, you can create more expressive, type-safe, and maintainable code, particularly when working with different states or variants of a concept.
Exercises
- Create a sealed class hierarchy to represent different shapes (Circle, Rectangle, Triangle) with methods to calculate area and perimeter for each shape.
- Implement a sealed class to represent different types of notifications in an app (Message, Alert, Update).
- Design a Result sealed class for a network operation that can be Success, Error, or Loading, then use it in a function that simulates an API call.
- Create a nested sealed class hierarchy to represent a file system with different types of files and directories.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)