In Kotlin, sealed classes (and sealed interfaces) let you model a closed, type-safe hierarchy: a fixed set of known subtypes. This is especially useful for things like UI state, results, commands, events, and domain-specific alternatives.
Basic idea
A sealed class restricts which classes can inherit from it.
sealed class Result
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
data object Loading : Result()
Now Result can only be one of the known subclasses: Success, Error, or Loading.
Exhaustive when
The main benefit is that Kotlin can check whether a when expression handles every possible subtype.
fun render(result: Result): String {
return when (result) {
is Success -> "Data: ${result.data}"
is Error -> "Error: ${result.message}"
Loading -> "Loading..."
}
}
Because Result is sealed, the compiler knows all possible cases. You do not need an else branch if all cases are covered.
If you add another subtype:
data object Empty : Result()
Then this when becomes incomplete, and the compiler will require you to handle Empty.
Prefer data object for singleton states
For sealed hierarchies with singleton cases, use data object:
sealed class UiState {
data object Loading : UiState()
data object Empty : UiState()
data class Success(val items: List<String>) : UiState()
data class Error(val cause: Throwable) : UiState()
}
Usage:
fun message(state: UiState): String =
when (state) {
UiState.Loading -> "Loading"
UiState.Empty -> "No items"
is UiState.Success -> "Loaded ${state.items.size} items"
is UiState.Error -> "Failed: ${state.cause.message}"
}
Sealed classes vs enums
Use an enum class when every case is a simple constant:
enum class Direction {
NORTH, SOUTH, EAST, WEST
}
Use a sealed class when cases may carry different data:
sealed class PaymentStatus {
data object Pending : PaymentStatus()
data class Paid(val receiptId: String) : PaymentStatus()
data class Failed(val reason: String) : PaymentStatus()
}
Sealed interfaces
A sealed interface is useful when subclasses may also extend another class, or when you want multiple sealed abstractions.
sealed interface NetworkState
data object Offline : NetworkState
data object Connecting : NetworkState
data class Online(val bandwidthMbps: Int) : NetworkState
Usage:
fun describe(state: NetworkState): String =
when (state) {
Offline -> "Offline"
Connecting -> "Connecting"
is Online -> "Online at ${state.bandwidthMbps} Mbps"
}
Nesting subclasses inside the sealed type
A common style is to define all cases inside the sealed class for readability:
sealed class AuthResult {
data class Success(val userId: String) : AuthResult()
data object InvalidCredentials : AuthResult()
data object NetworkFailure : AuthResult()
}
Then use it like this:
fun handle(result: AuthResult): String =
when (result) {
is AuthResult.Success -> "Welcome ${result.userId}"
AuthResult.InvalidCredentials -> "Invalid username or password"
AuthResult.NetworkFailure -> "Please check your connection"
}
Generic sealed result type
A common pattern is a generic result wrapper:
sealed class AppResult<out T> {
data class Success<T>(val value: T) : AppResult<T>()
data class Failure(val error: Throwable) : AppResult<Nothing>()
data object Loading : AppResult<Nothing>()
}
Example usage:
fun display(result: AppResult<String>): String =
when (result) {
is AppResult.Success -> "Value: ${result.value}"
is AppResult.Failure -> "Error: ${result.error.message}"
AppResult.Loading -> "Loading..."
}
The out T makes AppResult covariant, so AppResult<String> can be used where AppResult<Any> is expected.
Rules to remember
In modern Kotlin:
- Direct subclasses of a sealed class/interface must be in the same package.
- They must be declared in the same module.
- Sealed subclasses can be top-level or nested.
- A sealed class is abstract by default.
- Sealed classes cannot be instantiated directly.
Example:
sealed class Command
data class CreateUser(val name: String) : Command()
data class DeleteUser(val id: Long) : Command()
data object Sync : Command()
Exhaustive when as an expression
For exhaustiveness checking, prefer using when as an expression:
val text = when (state) {
UiState.Loading -> "Loading"
UiState.Empty -> "Empty"
is UiState.Success -> "Success"
is UiState.Error -> "Error"
}
If you use when only as a statement, exhaustiveness checking may be less useful depending on context and Kotlin version/settings.
Practical example: UI state
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Loaded(
val name: String,
val email: String
) : ProfileUiState
data class Failed(val message: String) : ProfileUiState
}
fun renderProfile(state: ProfileUiState): String =
when (state) {
ProfileUiState.Loading -> "Loading profile..."
is ProfileUiState.Loaded -> """
Name: ${state.name}
Email: ${state.email}
""".trimIndent()
is ProfileUiState.Failed -> "Could not load profile: ${state.message}"
}
Best practices
- Use sealed types to represent a closed set of alternatives.
- Use
data classfor cases with data. - Use
data objectfor singleton cases. - Avoid unnecessary
elsebranches inwhen; let the compiler check exhaustiveness. - Keep sealed hierarchies small and meaningful.
- Prefer sealed interfaces when you need more flexible inheritance.
- Prefer enums for simple constant-only sets.
In short: sealed classes give you algebraic data type-style modeling in Kotlin, with compiler-checked exhaustive handling through when.
