Kotlin Sealed Classes — Complete Guide with Examples
In this tutorial, you will learn about Kotlin Sealed Classes. We cover key concepts, practical examples, and best practices to help you master this topic.
Kotlin sealed classes and interfaces define restricted hierarchies where all possible subclasses are known at compile time, enabling exhaustive when expressions that eliminate the need for else branches.
What You'll Learn
- Declare sealed classes and interfaces
- Understand exhaustive when expressions
- Model UI state, network responses, and loading states
- Combine sealed classes with data classes
- Use sealed interfaces for multiplatform hierarchies
- Apply sealed class patterns in real-world scenarios
Why It Matters
Sealed classes solve the problem of representing a fixed set of possibilities. Instead of using strings, integers, or booleans to represent states, sealed classes make invalid states unrepresentable. The compiler enforces exhaustive handling in when expressions. If you add a new subclass, the compiler tells you every when block that needs updating. This catches bugs at compile time rather than runtime.
Real-World Use
DodaTech uses sealed classes to represent malware scan results: Clean, Infected(Threat), Error(String), and InProgress. The UI layer uses an exhaustive when to render the correct screen for each state. The network layer uses a sealed class for API responses, ensuring all response types are handled.
Learning Path
flowchart LR A[Objects] --> B[Sealed Classes\nYou are here] B --> C[Extensions] style B fill:#f90,color:#fff
Basic Sealed Class
A sealed class defines a restricted hierarchy. All direct subclasses must be in the same file.
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String, val code: Int) : Result()
data object Loading : Result()
data object Empty : Result()
}
fun handleResult(result: Result) {
when (result) {
is Result.Success -> println("Data: ${result.data}")
is Result.Error -> println("Error ${result.code}: ${result.message}")
Result.Loading -> println("Loading...")
Result.Empty -> println("No data available")
}
// No else needed: all subclasses are covered
}
fun main() {
handleResult(Result.Success("User profile loaded"))
handleResult(Result.Error("Not found", 404))
handleResult(Result.Loading)
handleResult(Result.Empty)
}
Output: Each state produces a different output. Adding a new subclass causes compilation errors in all when expressions until they are updated.
Sealed Interfaces
Kotlin 15+ supports sealed interfaces, which allow subclasses from multiple files as long as they are in the same module and compilation unit.
sealed interface NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>
data class Error(val message: String) : NetworkResult<Nothing>
data object Loading : NetworkResult<Nothing>
}
// Subclass in a different file in the same module
data class Timeout(val retryAfter: Int) : NetworkResult<Nothing>
fun <T> handleNetwork(result: NetworkResult<T>): String {
return when (result) {
is NetworkResult.Success -> "Success: ${result.data}"
is NetworkResult.Error -> "Error: ${result.message}"
is NetworkResult.Loading -> "Loading..."
is Timeout -> "Timeout, retry after ${result.retryAfter}s"
}
}
fun main() {
println(handleNetwork(NetworkResult.Success(42)))
println(handleNetwork(Timeout(30)))
}
Output: Sealed interfaces support multiple files within the same module, giving more flexibility in project organization.
Modeling UI State
Sealed classes excel at representing UI states in Android apps and other UI frameworks.
sealed class UiState<out T> {
data object Idle : UiState<Nothing>()
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>()
}
data class UserProfile(val name: String, val email: String, val avatarUrl: String?)
class UserProfileViewModel {
private var state: UiState<UserProfile> = UiState.Idle
fun loadProfile(userId: String) {
state = UiState.Loading
// Simulate network call
if (userId.isNotEmpty()) {
state = UiState.Success(
UserProfile("Alice", "alice@example.com", null)
)
} else {
state = UiState.Error("Invalid user ID")
}
}
fun render() {
when (val currentState = state) {
is UiState.Idle -> println("Waiting to load...")
is UiState.Loading -> println("Loading profile...")
is UiState.Success -> {
val (name, email, avatar) = currentState.data
println("Profile: $name ($email)")
if (avatar != null) println("Avatar: $avatar")
}
is UiState.Error -> {
println("Error: ${currentState.message}")
currentState.throwable?.printStackTrace()
}
}
}
}
fun main() {
val viewModel = UserProfileViewModel()
viewModel.render() // Output: Waiting to load...
viewModel.loadProfile("42")
viewModel.render() // Output: Profile: Alice (alice@example.com)
}
Output: The sealed class eliminates invalid states. You cannot have a Success with null data or an Error without a message.
Sealed Class with Data Classes and Objects
Combining data classes and data objects within sealed hierarchies provides maximum expressiveness.
sealed class PaymentResult {
// Singleton states with no data
data object Pending : PaymentResult()
data object Processing : PaymentResult()
// States with data
data class Completed(val transactionId: String, val amount: Double) : PaymentResult()
data class Declined(val reason: String, val code: String) : PaymentResult()
data class Refunded(val transactionId: String, val refundAmount: Double) : PaymentResult()
// Error state with optional details
data class Failed(val error: Throwable, val attemptNumber: Int = 1) : PaymentResult()
}
fun processPaymentResult(result: PaymentResult) {
when (result) {
PaymentResult.Pending -> println("Payment waiting for user action")
PaymentResult.Processing -> println("Payment processing...")
is PaymentResult.Completed -> println("Payment $${result.amount} completed: ${result.transactionId}")
is PaymentResult.Declined -> println("Declined [${result.code}]: ${result.reason}")
is PaymentResult.Refunded -> println("Refunded $${result.refundAmount}: ${result.transactionId}")
is PaymentResult.Failed -> println("Failed (attempt ${result.attemptNumber}): ${result.error.message}")
}
}
fun main() {
processPaymentResult(PaymentResult.Pending)
processPaymentResult(PaymentResult.Completed("TXN-12345", 99.99))
processPaymentResult(PaymentResult.Declined("Insufficient funds", "DEC-001"))
processPaymentResult(PaymentResult.Failed(RuntimeException("Timeout"), 3))
}
Output: Each payment state is represented distinctly. The exhaustive when ensures every case is handled.
When Expression Exhaustiveness
The compiler checks that all sealed class subclasses are covered in when expressions.
sealed class Shape {
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()
data class Triangle(val a: Double, val b: Double, val c: Double) : Shape()
}
fun area(shape: Shape): Double = when (shape) {
is Shape.Circle -> Math.PI * shape.radius * shape.radius
is Shape.Rectangle -> shape.width * shape.height
is Shape.Triangle -> {
// Heron's formula
val s = (shape.a + shape.b + shape.c) / 2
Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c))
}
// If we add a new Shape subclass, this when expression won't compile
// until we add the new branch
}
fun main() {
println(area(Shape.Circle(5.0))) // Output: 78.53981633974483
println(area(Shape.Rectangle(3.0, 4.0))) // Output: 12.0
println(area(Shape.Triangle(3.0, 4.0, 5.0))) // Output: 6.0
}
Output: The when expression is exhaustive. Adding a new Shape subclass requires updating this function.
Sealed Class versus Enum
Sealed classes and enums serve different purposes. Use sealed classes when subclasses need different data or behavior. Use enums when all instances are singletons with the same structure.
// Enum: fixed set of singletons, all have the same structure
enum class Color(val hex: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF)
}
// Sealed class: subclasses can have different properties and behaviors
sealed class HttpError {
data class BadRequest(val field: String, val message: String) : HttpError()
data class Unauthorized(val reason: String) : HttpError()
data object NotFound : HttpError()
data class ServerError(val code: Int, val details: String) : HttpError()
}
Enums are best for fixed categories (days of week, colors, directions). Sealed classes are best for variable states (API results, UI states, error types).
Sealed Class with Recursive Structure
Sealed classes can reference themselves, creating tree-like structures.
sealed class FileSystemNode {
abstract val name: String
data class File(
override val name: String,
val sizeBytes: Long
) : FileSystemNode()
data class Directory(
override val name: String,
val children: List<FileSystemNode>
) : FileSystemNode() {
val totalSize: Long get() = children.sumOf {
when (it) {
is File -> it.sizeBytes
is Directory -> it.totalSize
}
}
}
}
fun printTree(node: FileSystemNode, indent: String = "") {
when (node) {
is FileSystemNode.File -> println("${indent}File: ${node.name} (${node.sizeBytes} bytes)")
is FileSystemNode.Directory -> {
println("${indent}Dir: ${node.name}/")
node.children.forEach { printTree(it, "$indent ") }
}
}
}
fun main() {
val root = FileSystemNode.Directory("root", listOf(
FileSystemNode.File("readme.md", 1024),
FileSystemNode.Directory("src", listOf(
FileSystemNode.File("main.kt", 4096),
FileSystemNode.File("utils.kt", 2048)
)),
FileSystemNode.File("build.gradle.kts", 512)
))
printTree(root)
}
Output: The recursive sealed class models a file system naturally. Each Directory contains a list of FileSystemNode (which can be File or Directory).
Common Mistakes
Using sealed class when enum suffices: If every subclass is a Singleton with no data, use an enum. Sealed classes add complexity without benefit when there is no per-subclass data.
Defining subclasses in different files without sealed interface: Sealed classes require all subclasses in the same file. For multi-file hierarchies, use sealed interfaces (Kotlin 15+).
Forgetting data object for parameterless subclasses: Use data object instead of object for sealed subclasses. Data objects provide proper toString, equals, and hashCode.
Not using exhaustive when as expression: When a sealed class is the subject of a when expression, the result can be assigned to a variable. The compiler checks exhaustiveness for the return type.
Nesting sealed classes too deeply: Deeply nested sealed hierarchies become hard to navigate. Keep the hierarchy flat with a single level of subclasses.
Putting behavior in the sealed class itself: Sealed classes should define structure. Put behavior in functions with exhaustive when or in extension functions.
Practice Questions
- What makes a when expression on a sealed class exhaustive?
Answer: The compiler knows all possible subclasses. If you cover every subclass in the when branches, no else branch is needed. Adding a new subclass causes compilation errors in all when expressions until updated.
- What is the difference between sealed class and sealed interface?
Answer: Sealed classes require all subclasses in the same file. Sealed interfaces allow subclasses in different files within the same module. Sealed classes can have constructors and state; sealed interfaces cannot.
- When would you use data object inside a sealed class?
Answer: Use data object for sealed subclasses that carry no data and are singletons. Examples: Loading, Empty, Pending, Idle states.
- How does a sealed class compare to an enum?
Answer: Sealed class subclasses can have different properties and types. Enum entries are all the same type with the same structure. Use sealed for states with variable data, enums for fixed categories.
- Challenge: Model a vending machine with sealed classes. Include states (Idle, Selecting, Dispensing, OutOfStock), actions (SelectItem, InsertCoin, Dispense, Refill), and transitions between states enforcing valid operations.
Answer:
sealed class VendingState {
data object Idle : VendingState()
data class Selecting(val balance: Double) : VendingState()
data class Dispensing(val item: String, val change: Double) : VendingState()
data class OutOfStock(val message: String) : VendingState()
}
sealed class VendingAction {
data class SelectItem(val name: String, val price: Double) : VendingAction()
data class InsertCoin(val amount: Double) : VendingAction()
data object Dispense : VendingAction()
data class Refill(val items: Map<String, Int>) : VendingAction()
}
fun transition(state: VendingState, action: VendingAction): VendingState {
return when (state) {
is VendingState.Idle -> when (action) {
is VendingAction.SelectItem -> VendingState.Selecting(action.price)
is VendingAction.Refill -> state
else -> state
}
is VendingState.Selecting -> when (action) {
is VendingAction.InsertCoin -> {
val newBalance = state.balance + action.amount
if (newBalance >= state.balance) { // simplified: enough check
VendingState.Dispensing("Item", newBalance - state.balance)
} else {
VendingState.Selecting(newBalance)
}
}
else -> state
}
is VendingState.Dispensing -> when (action) {
VendingAction.Dispense -> VendingState.Idle
else -> state
}
is VendingState.OutOfStock -> when (action) {
is VendingAction.Refill -> VendingState.Idle
else -> state
}
}
}
Mini Project
Build a file upload manager using sealed classes. Requirements:
- UploadState sealed class: Idle, Preparing(File), Uploading(progress: Double), Paused(progress: Double), Completed(url: String), Failed(error: String)
- UploadAction sealed class: SelectFile(path), StartUpload, Pause, Resume, Cancel, Retry
- Transition function that takes current state and action, returns new state
- Validate that transitions are legal (e.g., cannot resume from Idle)
- Use data objects for singleton states
- Write a simulation that exercises all states
This project applies sealed classes to model real-world async workflows with invalid state prevention.
FAQ
What's Next
After mastering sealed classes, explore extensions to add functionality to existing classes. You can also learn about generics for type-safe reusable code.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro