Effective Kotlin Item 41: Use enum to represent a list of values

This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.

When we have to represent a constant set of possible options, a classic choice was to use an enum. For instance, if our website offers a concrete set of payment methods, we can represent them in our service using the following enum class:

enum class PaymentOption { CASH, CARD, TRANSFER }

Since each enum is an instance of the enum class, it can hold values that are always item-specific:

import java.math.BigDecimal enum class PaymentOption { CASH, CARD, TRANSFER; // This is a global mutable state, // so generally not the best practice (see Item 1) var commission: BigDecimal = BigDecimal.ZERO } fun main() { val c1 = PaymentOption.CARD val c2 = PaymentOption.CARD print(c1 == c2) // true, // because it is the same object c1.commission = BigDecimal.TEN print(c2.commission) // 10 // because c1 and c2 point to the same item val t = PaymentOption.TRANSFER print(t.commission) // 0, // because commission is per-item }

It is not a good practice to make such values mutable, as they are static per item, so we create global static mutable state (see Item 1: Limit mutability). However, this functionality is often used to attach some constant values per item. Those constant values can be attached during each item creation, using the primary constructor:

import java.math.BigDecimal enum class PaymentOption(val commission: BigDecimal) { CASH(BigDecimal.ONE), CARD(BigDecimal.TEN), TRANSFER(BigDecimal.ZERO) } fun main() { println(PaymentOption.CARD.commission) // 10 println(PaymentOption.TRANSFER.commission) // 0 val paymentOption: PaymentOption = PaymentOption.values().random() println(paymentOption.commission) // 0, 1 or 10 }

Kotlin enums can even have methods. Their implementations are also item-specific. When we define them, enum class itself needs to define an abstract method, and each item must override it:

enum class PaymentOption { CASH { override fun startPayment( transaction: Transaction ) { showCashPaymentInfo(transaction) } }, CARD { override fun startPayment( transaction: Transaction ) { moveToCardPaymentPage(transaction) } }, TRANSFER { override fun startPayment( transaction: Transaction ) { showMoneyTransferInfo() setupPaymentWatcher(transaction) } }; abstract fun startPayment(transaction: Transaction) }

Although this option is rarely used, because it is more convenient to use a primary constructor parameter of functional type:

enum class PaymentOption( val startPayment: (Transaction) -> Unit ) { CASH(::showCashPaymentInfo), CARD(::moveToCardPaymentPage), TRANSFER({ showMoneyTransferInfo() setupPaymentWatcher(it) }) }

An even more convenient option is to define an extension function. Notice that when we use enum values in the when expression, we do not need to add else clause when we cover all the values.

enum class PaymentOption { CASH, CARD, TRANSFER } fun PaymentOption.startPayment(transaction: Transaction) { when (this) { CASH -> showCashPaymentInfo(transaction) CARD -> moveToCardPaymentPage(transaction) TRANSFER -> { showMoneyTransferInfo() setupPaymentWatcher(transaction) } } }

The power of enum is that those items are specific and constant. We can get all the items using companion object values() function, or top-level enumValueOf function. We can also read enum from a String using companion objectvalueOf(String), or top-level enumValueOf(String).

enum class PaymentOption { CASH, CARD, TRANSFER } inline fun <reified T: Enum<T>> printEnumValues() { for(value in enumValues<T>()) { println(value) } } fun main() { val options = PaymentOption.values() println(options.map { it.name }) // [CASH, CARD, TRANSFER] val options2: Array<PaymentOption> = enumValues<PaymentOption>() println(options2.map { it.name }) // [CASH, CARD, TRANSFER] val option: PaymentOption = PaymentOption.valueOf("CARD") println(option) // CARD val option2: PaymentOption = enumValueOf<PaymentOption>("CARD") println(option2) // CARD printEnumValues<PaymentOption>() // CASH // CARD // TRANSFER }

Iterating over enum values is easy. They have the ordinal property, implement Comparable interface, and automatically implement toString, hashCode and equals. Their serialization and deserialization are simple (they are represented just by name), efficient and automatically supported by most libraries for serialization (like Moshi, Gson, Jackson, Kotlinx Serialization, etc). As a result, enums are perfect to represent a concrete set of constant values. So now, how do they compare to the sealed classes?

Enum or sealed class?

Sealed classes and interfaces, as shown in Item 39: Use sealed classes and interfaces to express restricted hierarchies, can also represent a set of values, but all their subclasses would have to be object declarations.

sealed class PaymentOption object Cash: PaymentOption() object Card: PaymentOption() object Transfer: PaymentOption()

For just a set of values, enum should be preferred. Those classes will not be automatically serialized or deserialized, they are not so easy to iterate over them (although we can do it with reflection), and do not have a natural order. Although there are some cases when we might choose this anyway.

Classes can keep values, so if we consider we might at some point need that, we prefer sealed classes. For instance, if we define messages to some actor, we use sealed classes even if now all the messages are object declarations, because it is likely we will need to add one or more that are holding instance-specific data in the future.

sealed class ManagerMessage() object ProductionStarted: ManagerMessage() object ProductionStopped: ManagerMessage() // In the future me might add something like this: class Alert(val message: String): ManagerMessage() // or we might need to add data to an existing message class ProductionStarted( val time: DateTime ): ManagerMessage()

When messages need to hold the data, it is a clear case - we need classes, not merely an enum. This is how our payment types holding data required to make transactions, could be represented:

sealed class Payment data class CashPayment( val amount: BigDecimal, val pointId: Int ): Payment() data class CardPayment( val amount: BigDecimal, val pointId: Int ): Payment() data class BankTransfer( val amount: BigDecimal, val pointId: Int ): Payment() fun process(payment: Payment) { when (payment) { is CashPayment -> { showPaymentInfo( payment.amount, payment.pointId ) } is CardPayment -> { moveToCardPaiment( payment.amount, payment.orderId ) } is BankTransfer -> { val transferRepo = BankTransferRepo() val transferDetails = transferRepo.getDetails() displayTransferInfo( payment.amount, transferDetails ) transferRepo.setUpPaymentWathcher( payment.orderId, payment.amount, transferDetails ) } } }

Summary

  • The advantage of enum classes is that they can be serialized and deserialized out of the box. They have companion object methods values() and valueOf(String). We can also get enum values by the type using enumValues() and enumValueOf(String) functions. Each enum value has ordinal and we can hold per-item data. They are perfect to represent a constant set of possible values.
  • Enum classes represent a concrete set of values, while sealed classes represent a concrete set of classes. Since those classes can be object declarations, we can use sealed classes to a certain degree instead of enums, but not the other way around.