Kotlin - Sealed Classes & Sealed Interfaces

Overview

Sealed classes and sealed interfaces in Kotlin provide a way to represent restricted class hierarchies. They're perfect for modeling algebraic data types, state machines, and ensuring exhaustive when expressions. This tutorial covers sealed class declarations, pattern matching, and practical applications.

๐ŸŽฏ Learning Objectives:
  • Understand sealed classes and their restrictions
  • Learn to create type-safe hierarchies with sealed interfaces
  • Master exhaustive when expressions
  • Implement state machines and result types
  • Apply sealed classes in real-world scenarios

Sealed Classes Basics

A sealed class restricts which classes can inherit from it. All subclasses must be declared in the same file (or nested within the sealed class).

Basic Sealed Class Declaration

// Sealed class for representing mathematical expressions
sealed class Expression

// Subclasses in the same file
data class Number(val value: Double) : Expression()
data class Sum(val left: Expression, val right: Expression) : Expression()
data class Multiply(val left: Expression, val right: Expression) : Expression()
object Pi : Expression()

// Exhaustive when expression
fun evaluate(expression: Expression): Double = when (expression) {
    is Number -> expression.value
    is Sum -> evaluate(expression.left) + evaluate(expression.right)
    is Multiply -> evaluate(expression.left) * evaluate(expression.right)
    is Pi -> Math.PI
    // No else clause needed - compiler knows all cases are covered
}

fun main() {
    val expression = Sum(
        Number(2.0),
        Multiply(Number(3.0), Pi)
    )
    
    println("Result: ${evaluate(expression)}")  // Result: 11.424777960769379
}

Sealed Classes with Properties and Methods

sealed class Result {
    data class Success(val data: T) : Result()
    data class Error(val message: String, val cause: Throwable? = null) : Result()
    object Loading : Result()
    
    // Common methods for all subclasses
    fun isSuccess(): Boolean = this is Success
    fun isError(): Boolean = this is Error
    fun isLoading(): Boolean = this is Loading
    
    // Transform success data
    inline fun  map(transform: (T) -> R): Result = when (this) {
        is Success -> Success(transform(data))
        is Error -> this
        is Loading -> this
    }
    
    // Get data or default value
    fun getOrDefault(default: T): T = when (this) {
        is Success -> data
        is Error -> default
        is Loading -> default
    }
}

// Usage example
fun fetchUserData(userId: Int): Result {
    return when {
        userId < 0 -> Result.Error("Invalid user ID")
        userId == 0 -> Result.Loading
        else -> Result.Success("User data for ID: $userId")
    }
}

fun main() {
    val results = listOf(
        fetchUserData(-1),
        fetchUserData(0),
        fetchUserData(123)
    )
    
    results.forEach { result ->
        when (result) {
            is Result.Success -> println("โœ… ${result.data}")
            is Result.Error -> println("โŒ Error: ${result.message}")
            is Result.Loading -> println("โณ Loading...")
        }
    }
    
    // Using map function
    val mappedResult = fetchUserData(123).map { data -> data.uppercase() }
    println("Mapped: ${mappedResult.getOrDefault("No data")}")
}

Sealed Interfaces (Kotlin 1.5+)

Sealed interfaces provide even more flexibility by allowing multiple inheritance while maintaining the exhaustiveness property.

Sealed Interface Example

sealed interface UIState
sealed interface LoadingState : UIState
sealed interface DataState : UIState

// Loading states
object InitialLoading : LoadingState
data class PartialLoading(val progress: Int) : LoadingState

// Data states
data class Success(val data: List) : DataState
data class Empty(val message: String) : DataState
data class Error(val error: String, val canRetry: Boolean = true) : DataState

// A class can implement multiple sealed interfaces
data class LoadingWithData(
    val data: List, 
    val isRefreshing: Boolean
) : LoadingState, DataState

fun handleUIState(state: UIState): String = when (state) {
    is InitialLoading -> "Showing initial loading spinner"
    is PartialLoading -> "Loading progress: ${state.progress}%"
    is Success -> "Displaying ${state.data.size} items"
    is Empty -> state.message
    is Error -> if (state.canRetry) "Error: ${state.error} (tap to retry)" else "Error: ${state.error}"
    is LoadingWithData -> "Refreshing ${state.data.size} items"
}

fun main() {
    val states = listOf(
        InitialLoading,
        PartialLoading(75),
        Success(listOf("Item 1", "Item 2", "Item 3")),
        Empty("No items found"),
        Error("Network error"),
        LoadingWithData(listOf("Cached item"), true)
    )
    
    states.forEach { state ->
        println(handleUIState(state))
    }
}

State Machine with Sealed Classes

// Traffic light state machine
sealed class TrafficLightState {
    object Red : TrafficLightState()
    object Yellow : TrafficLightState()
    object Green : TrafficLightState()
    
    fun next(): TrafficLightState = when (this) {
        is Red -> Green
        is Green -> Yellow
        is Yellow -> Red
    }
    
    fun duration(): Int = when (this) {
        is Red -> 30
        is Green -> 25
        is Yellow -> 5
    }
    
    fun canCross(): Boolean = when (this) {
        is Red -> false
        is Yellow -> false
        is Green -> true
    }
}

class TrafficLight {
    private var currentState: TrafficLightState = TrafficLightState.Red
    
    fun getCurrentState(): TrafficLightState = currentState
    
    fun changeLight() {
        currentState = currentState.next()
        println("Traffic light changed to: ${currentState::class.simpleName}")
        println("Duration: ${currentState.duration()} seconds")
        println("Can cross: ${if (currentState.canCross()) "Yes" else "No"}")
    }
}

fun main() {
    val trafficLight = TrafficLight()
    
    repeat(6) {
        println("--- Cycle ${it + 1} ---")
        trafficLight.changeLight()
        println()
    }
}

Advanced Pattern: Sealed Class with Generics

// API Response wrapper
sealed class ApiResponse {
    data class Success(val data: T, val metadata: Map = emptyMap()) : ApiResponse()
    data class Error(
        val code: Int,
        val message: String,
        val details: Map = emptyMap()
    ) : ApiResponse()
    
    // Factory methods
    companion object {
        fun  success(data: T, metadata: Map = emptyMap()): ApiResponse =
            Success(data, metadata)
        
        fun error(code: Int, message: String, details: Map = emptyMap()): ApiResponse =
            Error(code, message, details)
    }
    
    // Utility methods
    inline fun  fold(
        onSuccess: (T) -> R,
        onError: (Error) -> R
    ): R = when (this) {
        is Success -> onSuccess(data)
        is Error -> onError(this)
    }
    
    inline fun onSuccess(action: (T) -> Unit): ApiResponse {
        if (this is Success) action(data)
        return this
    }
    
    inline fun onError(action: (Error) -> Unit): ApiResponse {
        if (this is Error) action(this)
        return this
    }
}

// Usage in a service class
class UserService {
    fun getUser(id: Int): ApiResponse {
        return when {
            id <= 0 -> ApiResponse.error(400, "Invalid user ID")
            id == 404 -> ApiResponse.error(404, "User not found")
            else -> ApiResponse.success(
                User(id, "User $id", "[email protected]"),
                mapOf("cached" to "true", "timestamp" to System.currentTimeMillis().toString())
            )
        }
    }
}

data class User(val id: Int, val name: String, val email: String)

fun main() {
    val userService = UserService()
    val userIds = listOf(-1, 404, 123)
    
    userIds.forEach { id ->
        val result = userService.getUser(id)
        
        result
            .onSuccess { user -> 
                println("โœ… Found user: ${user.name} (${user.email})")
            }
            .onError { error -> 
                println("โŒ Error ${error.code}: ${error.message}")
            }
        
        // Alternative handling with fold
        val message = result.fold(
            onSuccess = { user -> "Welcome, ${user.name}!" },
            onError = { error -> "Failed to load user: ${error.message}" }
        )
        println("Message: $message\n")
    }
}

Real-World Example: Order Processing System

// Order states
sealed class OrderState {
    data class Pending(val orderId: String, val items: List) : OrderState()
    data class Processing(val orderId: String, val estimatedTime: Int) : OrderState()
    data class Shipped(val orderId: String, val trackingNumber: String) : OrderState()
    data class Delivered(val orderId: String, val deliveryTime: Long) : OrderState()
    data class Cancelled(val orderId: String, val reason: String) : OrderState()
    
    fun getOrderId(): String = when (this) {
        is Pending -> orderId
        is Processing -> orderId
        is Shipped -> orderId
        is Delivered -> orderId
        is Cancelled -> orderId
    }
    
    fun canCancel(): Boolean = when (this) {
        is Pending, is Processing -> true
        is Shipped, is Delivered, is Cancelled -> false
    }
    
    fun getStatusMessage(): String = when (this) {
        is Pending -> "Order pending - ${items.size} items"
        is Processing -> "Processing order - estimated $estimatedTime minutes"
        is Shipped -> "Order shipped - tracking: $trackingNumber"
        is Delivered -> "Order delivered on ${java.util.Date(deliveryTime)}"
        is Cancelled -> "Order cancelled: $reason"
    }
}

// Payment states
sealed class PaymentState {
    object NotPaid : PaymentState()
    data class Paid(val amount: Double, val method: String) : PaymentState()
    data class Refunded(val amount: Double, val reason: String) : PaymentState()
    data class Failed(val reason: String, val canRetry: Boolean) : PaymentState()
}

// Complete order with multiple sealed class properties
data class Order(
    val id: String,
    val orderState: OrderState,
    val paymentState: PaymentState
) {
    fun getOverallStatus(): String {
        val orderStatus = orderState.getStatusMessage()
        val paymentStatus = when (paymentState) {
            is PaymentState.NotPaid -> "Payment pending"
            is PaymentState.Paid -> "Paid $${paymentState.amount} via ${paymentState.method}"
            is PaymentState.Refunded -> "Refunded $${paymentState.amount}: ${paymentState.reason}"
            is PaymentState.Failed -> "Payment failed: ${paymentState.reason}"
        }
        return "$orderStatus | $paymentStatus"
    }
    
    fun canProcessOrder(): Boolean {
        return orderState is OrderState.Pending && paymentState is PaymentState.Paid
    }
}

// Order service
class OrderService {
    fun processOrder(order: Order): Order {
        return when {
            !order.canProcessOrder() -> {
                println("โŒ Cannot process order: ${order.getOverallStatus()}")
                order
            }
            order.orderState is OrderState.Pending -> {
                val newState = OrderState.Processing(order.id, 30)
                val updatedOrder = order.copy(orderState = newState)
                println("๐Ÿ”„ Processing order: ${updatedOrder.getOverallStatus()}")
                updatedOrder
            }
            else -> order
        }
    }
}

fun main() {
    val orders = listOf(
        Order(
            "ORD001",
            OrderState.Pending("ORD001", listOf("Book", "Pen")),
            PaymentState.Paid(25.99, "Credit Card")
        ),
        Order(
            "ORD002",
            OrderState.Pending("ORD002", listOf("Laptop")),
            PaymentState.NotPaid
        ),
        Order(
            "ORD003",
            OrderState.Shipped("ORD003", "TRK123456"),
            PaymentState.Paid(899.99, "PayPal")
        )
    )
    
    val orderService = OrderService()
    
    orders.forEach { order ->
        println("Order ${order.id}: ${order.getOverallStatus()}")
        orderService.processOrder(order)
        println()
    }
}

Best Practices

โœ… Best Practices:
  • Use sealed classes for finite, known sets of types
  • Prefer data classes for sealed class subclasses when possible
  • Take advantage of exhaustive when expressions
  • Use sealed interfaces for more flexible hierarchies
  • Include common properties and methods in the sealed parent
โŒ Common Pitfalls:
  • Don't use sealed classes for extensible hierarchies
  • Avoid deep sealed class hierarchies (prefer composition)
  • Remember sealed classes can't be extended from other files/modules
  • Be careful with sealed class serialization across versions

Practice Exercises

  1. Create a sealed class hierarchy for different types of network requests (GET, POST, PUT, DELETE)
  2. Implement a validation result type using sealed classes with success and various error types
  3. Design a file system representation with sealed classes for files, directories, and symlinks
  4. Create a calculator with sealed classes for different operations and expressions

Quick Quiz

  1. What's the main advantage of sealed classes over regular inheritance?
  2. Can you extend a sealed class from a different module?
  3. What happens if you don't handle all cases in a when expression with sealed classes?
Show Answers
  1. Sealed classes provide exhaustive when expressions, ensuring all possible cases are handled at compile time.
  2. No, all subclasses of a sealed class must be declared in the same file or as nested classes.
  3. The compiler will generate an error, ensuring exhaustiveness and preventing runtime errors.