Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 085 / 100

Kotlin enums vs sealed classes: best practices explained

Choose the right tool.

Expona Admin7 min read

Introduction

At some point in your Kotlin career, you'll face a decision that feels simple but carries real consequences: should I reach for an enum or a sealed class? Both let you represent restricted sets of possibilities, but they solve different problems. Understanding when to use Kotlin enums vs sealed classes: best practices explained will help you write cleaner, more maintainable code that your team will thank you for.

In this article, we'll walk through the practical differences, explore real-world scenarios from production systems, and show you exactly when each tool shines. Whether you're modeling payment statuses, API responses, or UI state, the choice matters for performance, serialization, and code clarity.

Enums for fixed constant sets

Enums shine when you have a closed, unchanging set of constants. Think of payment statuses, HTTP methods, or feature flags. Each enum case is a singleton instance, which means you get built-in memory efficiency and straightforward identity comparison.

Let's say you're building a payment processing system. You have exactly four states a payment can be in, and that never changes:

kotlin
enum class PaymentStatus {
    PENDING,
    PROCESSING,
    COMPLETED,
    FAILED
}

This is crystal clear. Every developer immediately understands the domain. You can use it in when expressions, switch on it exhaustively, and serialize it trivially. Enum instances are created once at class loading time, so memory is predictable and lean.

Here's a practical example from an e-commerce platform:

kotlin
data class Order(
    val id: String,
    val status: PaymentStatus,
    val total: BigDecimal
)

fun processPayment(order: Order): String = when (order.status) {
    PaymentStatus.PENDING -> "Charging card..."
    PaymentStatus.PROCESSING -> "Transaction in flight"
    PaymentStatus.COMPLETED -> "Payment confirmed"
    PaymentStatus.FAILED -> "Payment rejected"
}

Enums also support properties and functions. You might add retry logic or display names:

kotlin
enum class PaymentStatus(val displayName: String, val isTerminal: Boolean) {
    PENDING("Waiting", false),
    PROCESSING("In Progress", false),
    COMPLETED("Done", true),
    FAILED("Error", true);
    
    fun canRetry(): Boolean = !isTerminal
}

The key constraint: every enum case is identical in structure. If your cases need to carry different data, enums won't fit. That's where sealed classes step in.

Sealed classes when cases carry different data

Sealed classes let you define a restricted type hierarchy where each subclass can have its own shape. They're perfect when different cases need to hold different information.

Imagine you're handling API responses in a social media client. A success response includes user data and a timestamp. An error response includes an error code and message. A timeout has just a retry count. Enums can't express this:

kotlin
sealed class ApiResponse {
    data class Success(val user: User, val timestamp: Long) : ApiResponse()
    data class Error(val code: Int, val message: String) : ApiResponse()
    data class Timeout(val retryCount: Int) : ApiResponse()
}

Each case is a distinct class with its own properties. The compiler still lets you use exhaustive when to handle every possibility:

kotlin
fun handleResponse(response: ApiResponse) {
    when (response) {
        is ApiResponse.Success -> {
            println("User: ${response.user.name}, loaded at ${response.timestamp}")
        }
        is ApiResponse.Error -> {
            println("Failed with code ${response.code}: ${response.message}")
        }
        is ApiResponse.Timeout -> {
            println("Retry attempt ${response.retryCount}")
        }
    }
}

Sealed classes work brilliantly for modeling business logic flows. In a ride-hailing app, a driver's availability state might look like this:

kotlin
sealed class DriverState {
    object Offline : DriverState()
    object Online : DriverState()
    data class OnTrip(val tripId: String, val destination: Location) : DriverState()
    data class Paused(val reason: String, val resumeAfter: Long) : DriverState()
}

Notice how OnTrip carries trip-specific data while Offline doesn't need anything. Sealed classes handle this gracefully. Enums would force you to make every case carry the same fields, with most of them null—a code smell that makes type safety meaningless.

Both work with exhaustive when

The Kotlin compiler's exhaustiveness check is a superpower for both enums and sealed classes. When you use when without an else clause, the compiler verifies that every case is handled. Add a new enum value or sealed class subclass, and the compiler flags all the places you need to update.

For enums, this is straightforward:

kotlin
enum class OrderAction {
    SHIP,
    CANCEL,
    REFUND
}

fun performAction(order: Order, action: OrderAction) {
    when (action) {
        OrderAction.SHIP -> order.markShipped()
        OrderAction.CANCEL -> order.markCancelled()
        OrderAction.REFUND -> order.refundPayment()
        // Compiler error if you forget a case
    }
}

Sealed classes require the is keyword for type checking, but exhaustiveness still applies:

kotlin
sealed class PaymentResult {
    data class Approved(val transactionId: String) : PaymentResult()
    data class Declined(val reason: String) : PaymentResult()
    object Pending : PaymentResult()
}

fun updateOrderFromResult(order: Order, result: PaymentResult) {
    when (result) {
        is PaymentResult.Approved -> order.confirmPayment(result.transactionId)
        is PaymentResult.Declined -> order.failPayment(result.reason)
        is PaymentResult.Pending -> {} // explicit no-op is fine
    }
}

This exhaustiveness guarantee makes refactoring safe. In a CRM system managing deal states, adding a new state and being forced to handle it everywhere prevents silent bugs. It's one of Kotlin's best features for domain modeling.

Enums serialize easily

If your code needs to serialize to JSON, databases, or network protocols, enums have a huge advantage: they're dead simple. Most serialization libraries treat enums as strings or ordinals by default.

With Kotlinx Serialization:

kotlin
@Serializable
data class Payment(
    val id: String,
    val status: PaymentStatus
)

val json = Json.encodeToString(
    Payment("pay_123", PaymentStatus.COMPLETED)
)
// {"id":"pay_123","status":"COMPLETED"}

Sealed classes require more work. You need explicit serializers or type discriminators:

kotlin
@Serializable(with = ApiResponseSerializer::class)
sealed class ApiResponse {
    @Serializable
    data class Success(val user: User, val timestamp: Long) : ApiResponse()
    
    @Serializable
    data class Error(val code: Int, val message: String) : ApiResponse()
}

Or use a library that supports polymorphic serialization natively. In a SaaS backend storing transaction logs, enums serialize instantly while sealed classes need careful schema management. That said, many modern libraries (Kotlinx Serialization, Jackson) handle sealed classes well if configured properly.

Common mistakes

Mistake 1: Using enums when cases need different data shapes. Developers sometimes create an enum with optional properties, leaving most null. This defeats type safety:

kotlin
// Don't do this
enum class Result {
    SUCCESS, ERROR, TIMEOUT;
    
    var errorMessage: String? = null
    var retryCount: Int? = null
}

Use a sealed class instead. The type system becomes your documentation.

Mistake 2: Overcomplicating sealed classes with abstract functions. While you can add abstract functions to sealed classes, it often signals that a strategy or factory pattern would be cleaner. Keep sealed hierarchies focused on data representation.

Mistake 3: Forgetting that sealed classes aren't singletons. Unlike enums, each instance of a sealed subclass is a new object. If you're comparing identity with ==, remember that sealed class instances use structural equality (via data class):

kotlin
val error1 = ApiResponse.Error(404, "Not found")
val error2 = ApiResponse.Error(404, "Not found")
println(error1 == error2) // true (structural equality)
println(error1 === error2) // false (different instances)

Mistake 4: Creating enum factories when you should use sealed classes. Some teams work around enum limitations by creating factory functions that return different instances. That's a sign your model doesn't fit the enum constraint. Migrate to sealed classes and simplify.

Key takeaways

  • Use enums for a fixed, unchanging set of named constants where every case has the same structure (payment statuses, HTTP methods, feature flags).
  • Use sealed classes when your cases carry different data or need different structures (API responses, UI state variants, domain events).
  • Both support exhaustive when expressions, making the compiler your safety net during refactoring.
  • Enums serialize trivially; sealed classes need more configuration but are well-supported by modern libraries.
  • Never use enums with optional properties—that's a sign you need a sealed class.
  • Sealed class instances are not singletons; they use structural equality by default.
  • The choice between the two is often clear once you ask: "Do all my cases look the same?" If yes, enum. If no, sealed class.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles