In Kotlin, sealed classes and enums are both used to represent a restricted hierarchy of classes or instances. However, sealed classes provide more flexibility and capabilities compared to enums, making them a superior choice in many scenarios. This post explores the benefits of Kotlin sealed classes and demonstrates how they serve as an excellent alternative to enums.
What are Enums in Kotlin?
Enums (enumerations) in Kotlin represent a set of named constants. Each enum constant is an instance of the enum class. Enums are commonly used to define a finite set of options.
enum class Color {
RED,
GREEN,
BLUE
}
What are Sealed Classes in Kotlin?
Sealed classes are classes that define a restricted class hierarchy. All subclasses of a sealed class must be declared in the same file. This restriction allows the compiler to know all possible subtypes at compile time.
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
Why Sealed Classes are Better than Enums
Sealed classes offer several advantages over enums, including:
- State and Data: Sealed classes can hold state, allowing different subclasses to contain different data. Enums, on the other hand, cannot have properties specific to individual enum constants.
- Flexibility: Sealed classes can extend other classes and implement interfaces, providing more flexibility in designing class hierarchies.
- Smart Casting: When used in
when
expressions, the Kotlin compiler can automatically smart-cast instances of sealed classes, reducing the need for explicit type checks. - Extensibility: Although all subclasses must be defined in the same file, sealed classes can be used in external modules and libraries.
Examples of Sealed Classes vs. Enums
Let’s illustrate the advantages of sealed classes over enums with several examples.
Example 1: Representing UI States
Suppose you want to represent different states of a UI:
Using Enums:
enum class UIState {
LOADING,
SUCCESS,
ERROR
}
This works, but it’s limited. What if you want to associate data with SUCCESS
or an error message with ERROR
?
Using Sealed Classes:
sealed class UIState {
object Loading : UIState()
data class Success(val data: String) : UIState()
data class Error(val message: String) : UIState()
}
Now you can attach relevant data to each state, providing more context.
Example 2: Handling Network Responses
Representing different types of network responses:
Using Enums:
enum class NetworkResult {
SUCCESS,
ERROR,
LOADING
}
Again, this is basic but lacks detail.
Using Sealed Classes:
sealed class NetworkResult {
data class Success(val data: T) : NetworkResult()
data class Error(val exception: Exception) : NetworkResult()
object Loading : NetworkResult()
}
Here, you can include the actual data with the Success
state or the specific exception with the Error
state. The type T
adds even more flexibility.
Example 3: Implementing a Calculator
Consider implementing a simple calculator:
Using Enums:
enum class CalculatorOperation {
ADD,
SUBTRACT,
MULTIPLY,
DIVIDE
}
While this defines the operations, it doesn’t provide a way to execute them with different numbers directly within the enum.
Using Sealed Classes:
sealed class CalculatorOperation {
data class Add(val x: Double, val y: Double) : CalculatorOperation()
data class Subtract(val x: Double, val y: Double) : CalculatorOperation()
data class Multiply(val x: Double, val y: Double) : CalculatorOperation()
data class Divide(val x: Double, val y: Double) : CalculatorOperation()
fun execute(): Double =
when (this) {
is Add -> x + y
is Subtract -> x - y
is Multiply -> x * y
is Divide -> x / y
}
}
Now, each operation carries its operands and can execute the calculation directly.
Smart Casting with Sealed Classes
One of the most significant advantages of sealed classes is their seamless integration with when
expressions. The Kotlin compiler automatically smart-casts instances of sealed classes, ensuring exhaustive condition coverage and eliminating the need for explicit type checks.
fun processResult(result: Result) {
when (result) {
is Result.Success -> println("Success: ${result.data}") // Smart-casted to Success
is Result.Error -> println("Error: ${result.message}") // Smart-casted to Error
Result.Loading -> println("Loading...") // Smart-casted to Loading (Object)
}
}
In the above example, the compiler knows that result
is an instance of one of the sealed class’s subtypes, so it smart-casts accordingly within each branch of the when
expression.
Practical Implementation of Sealed Classes
Let’s consider a practical scenario of handling network responses using sealed classes in an Android application.
Step 1: Define the Sealed Class
sealed class ApiResponse {
data class Success(val data: T) : ApiResponse()
data class Error(val message: String) : ApiResponse()
class Loading : ApiResponse()
}
Step 2: Implement a Network Request
suspend fun fetchData(): ApiResponse {
return try {
// Simulate network request
delay(1000)
ApiResponse.Success("Data fetched successfully")
} catch (e: Exception) {
ApiResponse.Error("Failed to fetch data: ${e.message}")
}
}
Step 3: Handle the Response in the UI
import kotlinx.coroutines.*
fun processApiResponse() {
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
val result = fetchData()
when (result) {
is ApiResponse.Success -> {
println("Success: ${result.data}")
}
is ApiResponse.Error -> {
println("Error: ${result.message}")
}
is ApiResponse.Loading -> {
println("Loading...")
}
}
}
}
This complete example showcases how sealed classes can be used to handle different states of a network request effectively.
Conclusion
Kotlin sealed classes provide a more versatile and powerful alternative to enums for representing restricted hierarchies. They allow you to encapsulate state, leverage smart casting, and offer greater flexibility in class design. By adopting sealed classes, you can create more robust, expressive, and maintainable code. Understanding and utilizing sealed classes effectively is a key skill for modern Kotlin development.