article banner

Effective Kotlin Item 40: Prefer class hierarchies to tagged classes

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

It is not uncommon in large projects to find classes with a constant "mode" that specifies how this class should behave. We call such classes "tagged" as they contain a tag that specifies their mode of operation. However, there are many problems with them, most of which stem from the fact that different responsibilities from disparate modes fight for space in the same class even though they are generally distinguishable from each other. For instance, in the following snippet we can see a class that is used in tests to check if a value fulfills some criteria. This example is simplified, but it is a real sample from a large project1.

class ValueMatcher<T> private constructor( private val value: T? = null, private val matcher: Matcher ) { fun match(value: T?) = when (matcher) { Matcher.EQUAL -> value == this.value Matcher.NOT_EQUAL -> value != this.value Matcher.LIST_EMPTY -> value is List<*> && value.isEmpty() Matcher.LIST_NOT_EMPTY -> value is List<*> && value.isNotEmpty() } enum class Matcher { EQUAL, NOT_EQUAL, LIST_EMPTY, LIST_NOT_EMPTY } companion object { fun <T> equal(value: T) = ValueMatcher<T>( value = value, matcher = Matcher.EQUAL ) fun <T> notEqual(value: T) = ValueMatcher<T>( value = value, matcher = Matcher.NOT_EQUAL ) fun <T> emptyList() = ValueMatcher<T>( matcher = Matcher.LIST_EMPTY ) fun <T> notEmptyList() = ValueMatcher<T>( matcher = Matcher.LIST_NOT_EMPTY ) } }

There are many downsides to this approach:

  • Additional boilerplate code is needed because multiple modes are handled in a single class.
  • Properties are inconsistently used as they are used for different purposes. An object generally has more properties than most modes need as these properties might be required by other modes. For instance, in the example above, value is not used when the mode is LIST_EMPTY or LIST_NOT_EMPTY.
  • It is hard to protect state consistency and correctness when elements have multiple purposes and can be defined in a few ways.
  • The use of a factory method is often required because otherwise it is hard to ensure that objects are created correctly.

Instead of tagged classes, we have a better alternative in Kotlin: sealed classes. Instead of accumulating multiple modes in a single class, we should define multiple classes for each mode and use the type system to allow their polymorphic use. Then, the additional sealed modifier seals these classes as a set of alternatives. Here is how this could have been implemented:

sealed class ValueMatcher<T> { abstract fun match(value: T): Boolean class Equal<T>(val value: T) : ValueMatcher<T>() { override fun match(value: T): Boolean = value == this.value } class NotEqual<T>(val value: T) : ValueMatcher<T>() { override fun match(value: T): Boolean = value != this.value } class EmptyList<T>() : ValueMatcher<T>() { override fun match(value: T) = value is List<*> && value.isEmpty() } class NotEmptyList<T>() : ValueMatcher<T>() { override fun match(value: T) = value is List<*> && value.isNotEmpty() } }

This implementation is much cleaner as no multiple responsibilities are tangled with each other. Each object has only the required data and can define which parameters it expects. Using a type hierarchy makes it possible to overcome all the shortcomings of tagged classes. We can also easily add methods as extension functions using when.

fun <T> ValueMatcher<T>.reversed(): ValueMatcher<T> = when (this) { is EmptyList -> NotEmptyList<T>() is NotEmptyList -> EmptyList<T>() is Equal -> NotEqual(value) is NotEqual -> Equal(value) }

Tagged classes are not the same as classes using the state pattern

There is a popular pattern that is often confused with tagged classes. State pattern is a behavioral software design pattern that allows an object to alter its behavior when its internal state changes. This pattern is often used in front-end controllers, presenters, or view models (from MVC, MVP, and MVVM architectures). For instance, let’s say that you write an application that guides the user through morning exercises. Before every exercise, there is preparation time; at the end, there is a screen that states that the exercises are finished.

When we use the state pattern, we have a hierarchy of classes that represents different states, and we have a read-write property that we need in order to represent which state is the current one:

sealed class WorkoutState class PrepareState( val exercise: Exercise ) : WorkoutState() class ExerciseState( val exercise: Exercise ) : WorkoutState() object DoneState : WorkoutState() fun List<Exercise>.toStates(): List<WorkoutState> = flatMap { exercise -> listOf( PrepareState(exercise), ExerciseState(exercise) ) } + DoneState class WorkoutPresenter( /*...*/) { private var state: WorkoutState = states.first() //... }

The essential difference is that the classes that have state are mutable, and keep their state in other classes. Those other classes can be tagged classes, but we prefer to use sealed classes instead. As you can see, when we use the state pattern, this item still applies; we prefer to use sealed classes instead of tagged classes. The class that keep state typically has more functionalities, and their behavior often depends on this state.

Summary

In Kotlin, we use type hierarchies instead of tagged classes. We most often represent these type hierarchies as sealed classes. This practice does not collide with the state pattern, which is a popular and useful pattern in Kotlin. Both patterns actually cooperate as we prefer to use sealed hierarchies instead of tagged classes when we implement a state pattern. This is especially visible when we implement complex yet separable states in a single view.

1:

The full version contained many more modes.