Unlocking the Power of Sealed Classes in Kotlin: Design Patterns and Better Code Organization

Summit Kumar
6 min readApr 12, 2023

Having worked on multiple projects over the years, I understand the value of writing clean, maintainable code. In a recent project that involved handling numerous data types, I found that using sealed classes greatly improved the organization and management of the data. Furthermore, sealed classes helped enforce type safety, which ultimately made the code more robust and less prone to bugs. In this article, I’ll be sharing my experience with sealed classes in Kotlin, and how they can be leveraged to implement design patterns and enhance code organization.

What is a Sealed Class?
A sealed class is a class that is marked with the sealed keyword in Kotlin. It is used to define a closed set of subclasses. It allows you to define a restricted class hierarchy in which subclasses are predefined and finite. The subclasses of a sealed class are defined within the sealed class itself, and each subclass must be declared as inner or data or class, with no other modifiers allowed.

Syntax of a Sealed Class:
The syntax of a sealed class in Kotlin is as follows:

sealed class SealedClassName {
// Subclasses
class SubclassName1 : SealedClassName()
class SubclassName2 : SealedClassName()
// ...
}

Use Cases of Sealed Classes:
Sealed classes are useful in many situations where you have a fixed set of possible classes that need to be represented. Here are some common use cases for sealed classes:

Representing the Result of an Operation:
One common use case for sealed classes is to represent the result of an operation. For example, We might define a sealed class called Result with two subclasses: Success and Error.

sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
}

With this definition, We can use a when expression to handle all possible cases of Result, like this:

fun handleResult(result: Result) {
when(result) {
is Result.Success -> println(result.data)
is Result.Error -> println(result.message)
}
}

State Machine:
Another common use case for sealed classes is to represent the states of a state machine. For example, We might define a sealed class called State with subclasses representing different states of a game.

sealed class State {
object Initial : State()
object Running : State()
object Paused : State()
object Finished : State()
}

With this definition, we can use a when expression to handle all possible cases of State, like this:

fun handleState(state: State) {
when(state) {
is State.Initial -> println("The game is starting...")
is State.Running -> println("The game is running...")
is State.Paused -> println("The game is paused...")
is State.Finished -> println("The game is finished!")
}
}

Handling UI States:
A common use case for sealed classes is handling different UI states in Android applications. For example, you might define a sealed class called ViewState subclasses representing different UI states of a screen.

sealed class ViewState {
object Loading : ViewState()
data class Success(val data: List<String>) : ViewState()
data class Error(val message: String) : ViewState()
}

With this definition, We can use an when expression to handle all possible cases of ViewState, like this:

fun handleViewState(viewState: ViewState) {
when(viewState) {
is ViewState.Loading -> showLoadingIndicator()
is ViewState.Success -> showData(viewState.data)
is ViewState.Error -> showError(viewState.message)
}
}

Here are some more examples of how sealed classes can be used to implement design patterns:

State pattern:
The state pattern is used to represent the state of an object and the behavior that should be executed based on that state. Sealed classes can be used to define the different states of the object, and each state can have its own behavior.

sealed class State {
abstract fun handle()
}

object IdleState : State() {
override fun handle() {
// Do nothing
}
}

object ActiveState : State() {
override fun handle() {
// Do something when in active state
}
}

object InactiveState : State() {
override fun handle() {
// Do something when in inactive state
}
}

With this definition, We can use a when expression to handle all possible states of the object and execute the appropriate behavior, like this:

fun handleState(state: State) {
when(state) {
is IdleState -> state.handle()
is ActiveState -> state.handle()
is InactiveState -> state.handle()
}
}

Visitor pattern:
The visitor pattern is used to add new behavior to existing classes without modifying those classes. Sealed classes can be used to define the hierarchy of classes that the visitor will visit, and each subclass can implement a method that accepts the visitor.

sealed class Element {
abstract fun accept(visitor: Visitor)
}

class ConcreteElementA : Element() {
override fun accept(visitor: Visitor) {
visitor.visitConcreteElementA(this)
}
}

class ConcreteElementB : Element() {
override fun accept(visitor: Visitor) {
visitor.visitConcreteElementB(this)
}
}

interface Visitor {
fun visitConcreteElementA(element: ConcreteElementA)
fun visitConcreteElementB(element: ConcreteElementB)
}

With this definition, We can implement a new behavior by creating a new class that implements the Visitor interface and overrides the methods for each subclass of Element.

Factory pattern:
The factory pattern is used to create objects without exposing the instantiation logic to the client. Sealed classes can be used to define the different types of objects that the factory will create.

sealed class Product {
abstract fun getDescription(): String
}

class ConcreteProductA : Product() {
override fun getDescription(): String {
return "This is product A"
}
}

class ConcreteProductB : Product() {
override fun getDescription(): String {
return "This is product B"
}
}

object ProductFactory {
fun createProduct(type: String): Product {
return when(type) {
"A" -> ConcreteProductA()
"B" -> ConcreteProductB()
else -> throw IllegalArgumentException("Unknown product type")
}
}
}

With this definition, you can create new products by calling the createProduct method on the ProductFactory.

Factory method pattern:
The factory method pattern is used to create objects without specifying their concrete types. Sealed classes can be used to define the types of objects that can be created.

sealed class Animal {
abstract fun makeSound()
}

class Dog : Animal() {
override fun makeSound() {
println("Woof!")
}
}

class Cat : Animal() {
override fun makeSound() {
println("Meow!")
}
}

object AnimalFactory {
fun createAnimal(type: String): Animal? {
return when (type) {
"dog" -> Dog()
"cat" -> Cat()
else -> null
}
}
}

With this definition, you can create different types of animals by calling the createAnimal method with the appropriate type, like this:

val dog = AnimalFactory.createAnimal("dog") // Dog
val cat = AnimalFactory.createAnimal("cat") // Cat
val rabbit = AnimalFactory.createAnimal("rabbit") // null

Strategy pattern:
The strategy pattern is used to select an algorithm at runtime. Sealed classes can be used to define the different strategies that can be selected.

sealed class SortingStrategy {
abstract fun sort(array: IntArray)
}

object BubbleSort : SortingStrategy() {
override fun sort(array: IntArray) {
// implementation of bubble sort
}
}

object MergeSort : SortingStrategy() {
override fun sort(array: IntArray) {
// implementation of merge sort
}
}

class Sorter(private var strategy: SortingStrategy) {
fun setStrategy(strategy: SortingStrategy) {
this.strategy = strategy
}

fun sort(array: IntArray) {
strategy.sort(array)
}
}

With this definition, you can create a sorter object and set its strategy at runtime, like this:

val sorter = Sorter(BubbleSort)
sorter.sort(arrayOf(3, 2, 1)) // array is now sorted using bubble sort
sorter.setStrategy(MergeSort)
sorter.sort(arrayOf(3, 2, 1)) // array is now sorted using merge sort

Decorator pattern:
The decorator pattern is used to add functionality to an object dynamically, without changing its structure. Sealed classes can be used to define the base object and its decorator classes.

sealed class Coffee {
abstract fun getCost(): Double
abstract fun getDescription(): String
}

class BasicCoffee : Coffee() {
override fun getCost(): Double {
return 2.0
}

override fun getDescription(): String {
return "Basic coffee"
}
}

abstract class CoffeeDecorator(val coffee: Coffee) : Coffee()

class Milk(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun getCost(): Double {
return coffee.getCost() + 0.5
}

override fun getDescription(): String {
return coffee.getDescription() + ", with milk"
}
}

class Sugar(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun getCost(): Double {
return coffee.getCost() + 0.25
}

override fun getDescription(): String {
return coffee.getDescription() + ", with sugar"
}
}

With this definition, We can create different types of coffee by combining different decorators, like this:

val basicCoffee = BasicCoffee()
val coffeeWithMilk = Milk(basicCoffee)
val coffeeWithSugar = Sugar(basicCoffee)
val coffeeWithMilkAndSugar = Sugar(Milk(basicCoffee))

Conclusion:
Sealed classes are a powerful feature of Kotlin that can help you write cleaner, more concise, and expressive code. They provide a way to define restricted class hierarchies that can be used for a variety of purposes, including better code organization and implementation of design patterns. By using sealed classes, you can create more readable and maintainable code that is easier to extend and modify.

--

--

Summit Kumar

Tech-savvy BTech IT professional with a passion for latest technologies. Always seeking new challenges & opportunities to expand my knowledge. #KeepLearning #IT