Sealed classes are one of Kotlin's most powerful features for creating robust and type-safe code. They provide a way to represent restricted class hierarchies, where you know all possible subclasses at compile time. Think of them as enums on steroids – they can hold state and support multiple instances of the same type.
Key Takeaways
- Sealed classes restrict class hierarchies to known subclasses
- They enable exhaustive when expressions
- They're perfect for representing state machines and REST API responses
- Unlike enums, sealed classes can have multiple instances with different values
- They work great with Kotlin's pattern matching
Understanding Sealed Classes
A sealed class is a class that can only be inherited by classes in the same file. Let's start with a simple example:
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
// Usage
fun handleResult(result: Result) {
when (result) {
is Result.Success -> println("Got data: ${result.data}")
is Result.Error -> println("Error occurred: ${result.message}")
is Result.Loading -> println("Loading...")
// No else branch needed - compiler knows all possibilities
}
}
In this example, Result
represents all possible states of an operation. The compiler ensures we handle all cases in the when
expression.
Why Use Sealed Classes?
Sealed classes shine in several scenarios:
1. Representing API Results
sealed class ApiResponse<out T> {
data class Success<T>(val data: T) : ApiResponse<T>()
data class Error(val code: Int, val message: String) : ApiResponse<Nothing>()
object Loading : ApiResponse<Nothing>()
}
// Usage in a repository
fun fetchUser(id: String): ApiResponse<User> {
return try {
val user = api.getUser(id)
ApiResponse.Success(user)
} catch (e: Exception) {
ApiResponse.Error(500, e.message ?: "Unknown error")
}
}
2. State Machines
sealed class OrderState {
object Created : OrderState()
data class Paid(val paymentId: String) : OrderState()
data class Shipped(val trackingNumber: String) : OrderState()
data class Delivered(val deliveryDate: LocalDateTime) : OrderState()
data class Cancelled(val reason: String) : OrderState()
}
Best Practices
Place all sealed class subclasses in the same file. This makes the code more maintainable and clearly shows all possible states:
sealed class UIState {
object Initial : UIState()
object Loading : UIState()
data class Content(val items: List<Item>) : UIState()
data class Error(
val message: String,
val retry: Boolean = true
) : UIState()
}
2. Use Data Classes for States with Data
When your subclasses need to hold data, use data classes to get equals(), hashCode(), and toString() for free:
sealed class ValidationResult {
// Use object for stateless results
object Valid : ValidationResult()
// Use data class when you need to hold data
data class Invalid(
val errors: List<String>
) : ValidationResult()
}
3. Leverage Exhaustive When Expressions
Take advantage of Kotlin's smart casting and exhaustive when expressions:
fun processValidation(result: ValidationResult) {
val message = when (result) {
is ValidationResult.Valid -> "All good!"
is ValidationResult.Invalid -> {
// Smart cast allows direct access to errors
"Found ${result.errors.size} errors: ${result.errors.joinToString()}"
}
}
println(message)
}
4. Consider Using Generic Sealed Classes
For reusable components, consider making your sealed classes generic:
sealed class Option<out T> {
data class Some<T>(val value: T) : Option<T>()
object None : Option<Nothing>()
}
// Usage
fun divide(a: Int, b: Int): Option<Int> {
return if (b == 0) Option.None
else Option.Some(a / b)
}
Common Pitfalls to Avoid
- Don't create unnecessary hierarchies:
// Don't do this - overcomplicated
sealed class Animal {
sealed class Mammal : Animal() {
data class Dog(val name: String) : Mammal()
data class Cat(val name: String) : Mammal()
}
}
// Better - flatter hierarchy
sealed class Animal {
data class Dog(val name: String) : Animal()
data class Cat(val name: String) : Animal()
}
- Avoid using sealed classes for simple enumerations:
// Don't do this - use enum instead
sealed class Direction {
object North : Direction()
object South : Direction()
object East : Direction()
object West : Direction()
}
// Do this instead
enum class Direction {
NORTH, SOUTH, EAST, WEST
}
Conclusion
Sealed classes are a powerful tool in Kotlin's type system that helps you write more maintainable and type-safe code. They're perfect for representing finite sets of possibilities, especially when those possibilities need to carry different types of data. By following these best practices and understanding when to use sealed classes, you can create more robust and expressive code.