Sealed Classes in Kotlin: Turning Finite Hierarchies into Compile‑Time Guarantees
Sealed classes let you model a small, well‑defined set of states with compile‑time exhaustiveness. This blog walks through a real‑world network result wrapper, shows how smart casts work, and lists the trade‑offs of using sealed classes in Kotlin.
09 May 2026, 09:26 UTC

Why Finite Type Hierarchies Matter
In many applications you model a value that can be in one of a small, well‑defined set of states. Examples include UI screen states (loading, success, error), network responses (Ok, NotFound, ServerError), or domain events (Created, Updated, Deleted). Traditionally developers use enums or a base class with a handful of subclasses. Enums lose the ability to carry payloads, while open classes invite accidental extensions and runtime type checks.
Enter Kotlin Sealed Classes
A sealed class is a special kind of abstract class that restricts subclassing to the same file. This restriction gives the compiler full knowledge of every possible subtype, allowing:
- Exhaustive
whenchecks – the compiler will warn if you forget a branch. - Smart casting – no explicit casts needed after a type check.
- Value‑based data classes – each subtype can be a data class, gaining equals(), hashCode(), and toString().
- Factory encapsulation – a companion object can expose a single constructor that returns the appropriate subtype.
Concrete Example: A Network Result Wrapper
Below is a minimal, but realistic, sealed class used to represent the result of a network call. All subclasses live in Result.kt – the same file that declares the sealed class.
// Result.kt
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
object Loading : Result<Nothing>()
companion object {
fun <T>(data: T?): Result<T> =
if (data == null) Error(IllegalStateException("No data")) else Success(data)
}
}
Notice:
- The
SuccessandErrorsubclasses are data classes, so we get usefultoString()and equality semantics. - The
Loadingstate is a singleton object – no payload needed. - The companion object’s
funhides the constructor logic, keeping callers in control.
Using the Result in a ViewModel
When consuming Result in a when expression, the compiler enforces exhaustiveness:
fun render(result: Result<String>) {
when (result) {
is Result.Success -> showData(result.data)
is Result.Error -> showError(result.exception)
is Result.Loading -> showLoading()
}
}
If you add a new subtype to Result, the IDE will immediately flag the missing branch in render. This compile‑time safety eliminates a class of runtime bugs where an unexpected state slips through.
Smart Casts Without Explicit Casting
Because the compiler knows the exact type of result inside each when branch, you can access data directly:
is Result.Success -> {
// no cast needed – data is inferred as String
println("Got ${result.data}")
}
Outside the when, result is still typed as Result<String>. If you need the payload, you must cast or pattern match again.
Generics and Type Parameters
Sealed classes can be generic, as shown. The type parameter T is covariant (declared with out), meaning Result<String> can be used where Result<Any> is expected. This matches the read‑only nature of the payload – you can read data but not modify it.
Trade‑offs and Limitations
- File‑size constraint – all subclasses must reside in the same file. Large hierarchies can become unwieldy, and moving a subtype to another module forces you to duplicate the sealed class.
- Final by default – sealed classes cannot be subclassed further outside the file. If you need an open hierarchy, sealed classes are unsuitable.
- Java interoperability – Java code cannot extend a sealed class. In Kotlin‑only codebases this is fine, but mixed Java‑Kotlin projects may need careful design.
- Reflection overhead – while you can list subclasses via
kotlin.reflect.full.sealedSubclasses, it incurs runtime costs and is not available in Java.
How to Verify Exhaustiveness in Your Project
- Write a
whenexpression that covers all known subclasses. - Intentionally comment out one branch and observe the compiler warning:
When should be exhaustive. - Add a new subclass and run your unit tests; the tests should compile and detect the missing branch if you still commented one out.
Actionable Takeaways
- Use sealed classes for any domain concept that has a finite, known set of states.
- Prefer sealed classes over enums when you need payloads or richer behavior.
- Encapsulate factory logic in the companion object to keep callers simple.
- When your hierarchy grows beyond a single file, consider a sealed interface (available in Kotlin 1.5+) or split the sealed class into nested classes.
In short, sealed classes give you compile‑time safety for a common pattern in Kotlin codebases. They reduce boilerplate, prevent runtime surprises, and make your intent explicit to anyone reading the code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.