Table of Contents
- What Are Sealed Classes?
- Syntax and Basic Usage
- Key Characteristics of Sealed Classes
- Sealed Classes vs. Enums vs. Open Classes
- Practical Use Cases
- Advanced Scenarios
- Common Pitfalls and Best Practices
- Conclusion
- References
What Are Sealed Classes?
A sealed class in Kotlin is a special type of class that restricts the creation of subclasses. Unlike regular open classes (which allow subclassing anywhere), sealed classes enforce that all direct subclasses must be declared in the same file as the sealed class itself (or as nested classes within it). This restriction creates a closed hierarchy, meaning the set of possible subclasses is known and fixed at compile time.
At their core, sealed classes are designed to represent fixed type hierarchies where you want to ensure all possible variants are explicitly defined and accounted for. Think of them as a more flexible alternative to enums, enabling richer state representation while maintaining the safety of a closed set of options.
Syntax and Basic Usage
Defining a Sealed Class
To declare a sealed class, use the sealed modifier. Sealed classes are implicitly abstract, so you cannot instantiate them directly. Instead, you define subclasses (either nested or in the same file) to represent specific variants.
Example: A Simple Sealed Class
// Sealed class declaration (abstract by default)
sealed class UiState
// Subclasses declared in the same file (can be nested or top-level)
object Loading : UiState()
data class Success(val data: String) : UiState()
data class Error(val message: String, val code: Int) : UiState()
Key Syntax Notes:
- No Direct Instantiation: You cannot create an instance of
UiStateitself (e.g.,UiState()will throw a compile error). - Subclass Location: Subclasses must be in the same file as the sealed class. Prior to Kotlin 1.1, subclasses had to be nested, but modern Kotlin allows top-level subclasses in the same file.
- Subclass Types: Subclasses can be
objects (singletons),data classes (for stateful variants), regular classes, or even other sealed classes.
Key Characteristics
Sealed classes have several defining traits that make them unique:
1. Restricted Subclassing
All direct subclasses must be declared in the same file as the sealed class. This ensures the hierarchy is closed and known at compile time.
2. Exhaustive when Statements
The Kotlin compiler recognizes sealed class hierarchies as closed, enabling exhaustive checks in when expressions. If you use a when expression with a sealed class, the compiler will enforce that all possible subclasses are handled, eliminating runtime errors from missing cases.
Example: Exhaustive when Check
fun handleUiState(state: UiState): String = when (state) {
is Loading -> "Loading..."
is Success -> "Data loaded: ${state.data}"
is Error -> "Error (${state.code}): ${state.message}"
// No "else" clause needed—compiler verifies all subclasses are covered
}
If you later add a new subclass (e.g., PartialLoad), the compiler will flag all when expressions that don’t handle it, preventing silent failures.
3. Abstract by Default
Sealed classes cannot be instantiated directly. They are implicitly abstract, so you must use their subclasses to represent concrete cases.
4. Flexible Subclass Types
Subclasses of sealed classes can be:
objects (for singleton states likeLoading).data classes (for stateful variants withequals(),hashCode(), andtoString()auto-generated).- Regular classes (for complex logic).
- Even other sealed classes or
openclasses (to allow further subclassing, though restricted to the same file).
Sealed Classes vs. Enums vs. Open Classes
Understanding how sealed classes compare to enums and open classes is critical for choosing the right tool for the job. Let’s break down their differences:
| Feature | Sealed Classes | Enums | Open Classes |
|---|---|---|---|
| Purpose | Fixed set of types (with optional state) | Fixed set of instances (singletons) | Unrestricted subclassing (anywhere) |
| Instantiation | Abstract (instantiate subclasses only) | Singleton instances (enum constants) | Directly instantiable |
| State | Subclasses can hold state (e.g., data class Success(val data: T)) | No per-instance state (constants are fixed) | Unlimited state flexibility |
| Exhaustiveness | Enforced by compiler in when expressions | Enforced by compiler in when expressions | Not enforced (unknown subclasses possible) |
| Use Case | State management, result handling, ADTs | Fixed constants (e.g., Color.RED) | General hierarchies (e.g., Animal → Dog) |
When to Use Which?
- Enums: Use when you need a fixed set of singleton values with no state (e.g.,
Direction.NORTH,PaymentMethod.CREDIT_CARD). - Sealed Classes: Use when you need a fixed set of types with varying state (e.g.,
Success(data),Error(message)). - Open Classes: Use when you want to allow unrestricted subclassing (e.g., library APIs designed for extension).
Practical Use Cases
Sealed classes shine in scenarios where you need to model a fixed set of related states or outcomes. Here are some common use cases:
1. UI State Management
Sealed classes are ideal for representing UI states, where the interface can only be in one of a few distinct states (e.g., loading, success, error).
sealed class ScreenState {
object Loading : ScreenState()
data class Content(val items: List<String>) : ScreenState()
data class Error(val message: String, val retryAction: () -> Unit) : ScreenState()
}
// Usage in a ViewModel or UI controller
fun renderState(state: ScreenState) {
when (state) {
is ScreenState.Loading -> showLoadingSpinner()
is ScreenState.Content -> displayItems(state.items)
is ScreenState.Error -> showError(state.message, state.retryAction)
}
}
2. Result Handling
APIs or function calls often return one of two outcomes: success with data or failure with an error. Sealed classes model this cleanly.
sealed class ApiResult<out T> {
data class Success<out T>(val data: T) : ApiResult<T>()
data class Error(val exception: Exception) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
}
// Usage with a repository
suspend fun fetchUser(): ApiResult<User> {
return try {
val user = apiService.getUser()
ApiResult.Success(user)
} catch (e: Exception) {
ApiResult.Error(e)
}
}
3. Algebraic Data Types (ADTs)
In functional programming, sealed classes enable sum types (algebraic data types), where a value can be one of several types. For example, a Shape could be a Circle, Square, or Triangle.
sealed class Shape {
data class Circle(val radius: Double) : Shape()
data class Square(val sideLength: Double) : Shape()
data class Triangle(val base: Double, val height: Double) : Shape()
}
fun calculateArea(shape: Shape): Double = when (shape) {
is Shape.Circle -> Math.PI * shape.radius * shape.radius
is Shape.Square -> shape.sideLength * shape.sideLength
is Shape.Triangle -> 0.5 * shape.base * shape.height
}
4. Parser Implementations
Sealed classes help model token types in parsers, ensuring all token variants are explicitly handled.
sealed class Token {
data class Identifier(val name: String) : Token()
data class Number(val value: Int) : Token()
object LParen : Token() // "("
object RParen : Token() // ")"
object Plus : Token() // "+"
}
fun tokenize(input: String): List<Token> {
// Logic to convert input into a list of Token subclasses
}
Advanced Scenarios
Sealed Interfaces (Kotlin 1.5+)
Kotlin 1.5 introduced sealed interfaces, extending the sealed concept to interfaces. Sealed interfaces work similarly to sealed classes but for interfaces, enabling closed hierarchies of interface implementations.
Example: Sealed Interface
sealed interface Operation {
data class Add(val a: Int, val b: Int) : Operation
data class Subtract(val a: Int, val b: Int) : Operation
data class Multiply(val a: Int, val b: Int) : Operation
}
fun evaluate(op: Operation): Int = when (op) {
is Operation.Add -> op.a + op.b
is Operation.Subtract -> op.a - op.b
is Operation.Multiply -> op.a * op.b
}
Sealed interfaces are useful when you want to enforce a closed set of implementations for an interface (e.g., mathematical operations, command patterns).
Nested Sealed Classes
Sealed classes can be nested inside other classes or interfaces, helping organize related hierarchies.
class Calculator {
sealed class Operation {
data class Add(val a: Int, val b: Int) : Operation()
data class Subtract(val a: Int, val b: Int) : Operation()
}
fun compute(op: Operation): Int = when (op) {
is Operation.Add -> op.a + op.b
is Operation.Subtract -> op.a - op.b
}
}
Open Subclasses of Sealed Classes
Subclasses of sealed classes can themselves be open, allowing further subclassing—but only within the same file.
sealed class Vehicle
open class Car : Vehicle() // Open for subclassing
class Sedan : Car() // Allowed (same file)
class SUV : Car() // Allowed (same file)
object Bicycle : Vehicle() // Singleton subclass
Common Pitfalls and Best Practices
Pitfalls to Avoid
-
Forgetting Exhaustiveness in
whenStatements
When usingwhenas a statement (not an expression), the compiler only warns about missing cases. Usewhenas an expression (assign its result to a variable) to enforce exhaustiveness.// Bad: Compiler warns but doesn't error (statement form) fun handleState(state: UiState) { when (state) { is Loading -> showLoading() // Error and Success not handled—compiler warns but allows } } // Good: Compiler errors on missing cases (expression form) fun getStateMessage(state: UiState): String = when (state) { is Loading -> "Loading..." is Success -> "Success" is Error -> "Error" // All cases handled } -
Placing Subclasses in the Wrong File
Subclasses must live in the same file as the sealed class. The compiler will throw an error if you try to declare a subclass elsewhere. -
Overusing Sealed Classes
Don’t use sealed classes when enums suffice. For example,Color.REDis better as an enum than a sealed class withobject Red : Color().
Best Practices
-
Keep Hierarchies Small and Focused
Avoid bloated sealed classes with dozens of subclasses. Split into smaller sealed classes if the hierarchy becomes unmanageable. -
Use
data classfor Stateful Subclasses
data classauto-generatesequals(),hashCode(), andtoString(), making stateful subclasses easier to work with (e.g.,data class Success(val data: T)). -
Document the Hierarchy
Add comments to the sealed class and its subclasses to explain their purpose, especially if the hierarchy is non-trivial. -
Leverage Sealed Interfaces for Interface Hierarchies
Use sealed interfaces when you need a closed set of interface implementations (e.g.,Commandpatterns with fixed command types).
Conclusion
Kotlin’s sealed classes are a powerful tool for modeling fixed, closed hierarchies. By restricting subclassing and enabling exhaustive when checks, they promote type safety, reduce runtime errors, and make code more maintainable. Whether you’re handling UI states, API results, or algebraic data types, sealed classes provide a clean, expressive way to represent complex logic with clarity.
By choosing sealed classes over enums or open classes in the right scenarios, you’ll write code that’s not only robust but also self-documenting—making it easier for you and your team to reason about and extend over time.