A Deep Dive into Sealed Classes - Wednesday's Kotlin Kuppa #3
Hello, it's Mirco! Welcome to the third issue of Wednesday's Kotlin Kuppa ☕️
Every Wednesday, I'll brew a fresh, hands-on Kotlin tip just for you. Are you curious about my previous insights? Check out my take on Function Types 🔗.
Don't miss out on these weekly sips—subscribe if you haven't!
This week, we're venturing into a territory that might seem a unusual if you do not know Kotlin - sealed classes and interfaces. What exactly is a sealed class, and what makes it a valuable asset in your Kotlin toolkit? Let's embark on this journey of discovery together!
Unveiling the Sealed Keyword
We create a sealed class simply by using the sealed
keyword:
sealed class ProcessingStep(val startedAt: Instant, val finishedAt:Instant?)
class StartCalculation(startedAt: Instant, finishedAt:Instant?) : ProcessingStep(startedAt, finishedAt)
class LoadData(startedAt: Instant, finishedAt:Instant?) : ProcessingStep(startedAt, finishedAt)
This principle holds true for interfaces as well, but for the sake of this discussion, we'll concentrate on classes. It's worth noting, though, that sealed classes and interfaces share similar behaviors. So, what sets a sealed class apart? Let's delve into its traits:
By their very nature, sealed classes are abstract. Direct instantiation of a class like
ProcessingStep
is off the table.All direct subclasses must reside within the same package as the parent.
Owing to the above, the creation of new subclasses of
ProcessingStep
at runtime or external libraries is an impossibility.
Bear in mind, while direct descendants of a sealed class are bound to its package, indirect subclasses can emerge anywhere.
At first glance, these might appear as a lot of limitations 🚫. Why opt for an abstract class with such constraints?
The beauty of these limitations lies in their consequence: every child of ProcessingStep
is known at compile time. This feature shines in when
expressions, allowing you to bypass the else
clause:
fun printStep(step: ProcessingStep) {
when (step) {
is StartCalculation -> println("Started at ${step.startedAt}.")
is LoadData -> println("Loading data since ${step.startedAt}.")
}
}
One might draw parallels with Enums
, where new members can't be added at runtime, making all Enum values known at compile time. True, but here's the twist: Enums are singletons, whereas sealed classes are not. Each Enum type exists as a solitary instance, in contrast to the boundless instances you can spawn from a sealed class.
Sealed Classes in Practical Use
Let's turn our attention to real-world applications of sealed classes and interfaces.
Result
is a prime example of a sealed class. A Result
could manifest as either Success<T>
or Error
. Opting for this class over throwing exceptions is a strategic choice. Essentially, it's a more focused variant of the broader Either
type. The outcome is binary - success or error, leaving no room for a third alternative. Subsequent code can then gracefully handle the result using a when expression.
To my surprise, Kotlin's built-in result type is not a sealed class. Here's how you can implement it on your own:
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
}
fun divide(a: Int, b: Int): Result<Int> {
return if (b == 0) {
Result.Error("Division by zero is not allowed")
} else {
Result.Success(a / b)
}
}
It becomes evident that sealed classes and interfaces are adept at de-cluttering code, primarily by negating the need for the else case in when expressions.
In more complex scenarios, where multiple outcomes are possible, sealed classes offer an elegant solution. For instance, in a network request, the response could be Success, Failure, or Pending, each carrying its specific data. This approach not only simplifies branching logic but also enhances readability and maintainability.
Moreover, the use of sealed classes in domain modeling provides a clear representation of the domain's finite states. It ensures that all possible states are accounted for at compile time, leading to safer and more reliable code. This is particularly useful in applications where business logic is complex and prone to change, as it offers a structured way to capture these evolving requirements.
Pitfalls and Downsides
Sealed classes in Kotlin offer limited flexibility, particularly in larger projects or modular architectures. 🏗️ Their requirement for all direct subclasses to reside within the same package can lead to a cluttered namespace, impacting package organization and code clarity. This package-level visibility also enforces a rigid structure, sometimes at odds with preferred architectural patterns.
Inappropriate application, such as in scenarios better suited for simpler constructs like enums or standard classes, leads to over-engineered solutions. This not only complicates the code but also burdens developers with increased complexity and maintenance challenges. 🧠
Careful consideration is key to leveraging the benefits of sealed classes without falling into these traps.
To Conclude
Before wrapping up, let's quickly recap the key takeaways of using sealed classes and interfaces in Kotlin. Here are three crucial points to remember:
Versatility & Intuition: 🌟 Sealed classes in Kotlin simplify complex coding, aligning with modern software design.
Compile-Time Safety: 🔒 Ensuring all subclasses are known at compile time, sealed classes boost reliability and reduce errors.
Code Clarity: 📚 Streamlining logic and enhancing readability, sealed classes make code management more efficient.
Thank you for investing your time in reading this post! 🙏 If you found it valuable, please leave a comment 💬 and share it with your network 📢.
If you find this valuable, please consider to buy me a virtual coffee on Ko-Fi!