Recommended Reading

11 Minutes
An introduction to Kotlin Sealed Class
Fix Bugs Faster! Log Collection Made Easy
Sealed classes are a special kind of class in Kotlin, used to create restricted hierachies — where the full list of sub-classes is strictly limited, and known in advance.
This is particularly useful when we want to enhance the power of Kotlin’s when
statement, model a type that has a fixed set of possible variations, or represent a fixed group of states or outcomes (such as network responses, UI states and form validations).
In this article, we’ll look at how sealed classes help with type safety, code readability and error reduction throughout our Kotlin implementation. You’ll also see some complex use-cases, like using sealed classes with Android architecture components and how they can be handled, or dealing with multiple states in UI components.
Table of Contents
What are the specific benefits of sealed classes?
Sealed classes are ideal when we want a controlled hierarchy. They help the compiler handle cases, provide extra type safety, and ensure that all scenarios are covered, as they represent a finite number of types or states. Specific benefits include:
- Simplicity:
when
is Kotlin’s choice engine, allowing us to enforce if>then logic throughout our app. With sealed classes, we can ensure thatwhen
covers every single class: in other words, we don’t accidentally forget one of them and leave it outside our logic umbrella. - Reliability: The compiler forces us to handle every case every time, which prevents runtime errors, and prevents unexpected subtypes anywhere in the code.
- Readability and maintenance: As well as giving our hierarchies a clear structure, sealed classes allow developers to create derived types easily.
- No surprises: No-one, no-one, can extend the hierarchy in ways we don’t anticipate.
Sealed classes are also an excellent choice for domain modelling and business logic. With the power of sealed classes, we can represent different states or conditions in our domain; represent our complex business logic by being explicit about all the possible states; encapsulate the domain-specific data within the sealed classes or their derivative types (this ensures consistency in the enforcement of business rules); and represent different error types and responses, so we can handle our errors in a structured, predictable way.
How do sealed classes work?
Simple – we apply the Sealed
modifier before the class
(or interface
) keyword. This tells the compiler that all classes in the hierarchy are defined in the same file.
Here’s some code to show you:
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
//showing example of seald class for NetworkResult
sealed class NetworkResult
data class Success(val data: String) : NetworkResult()
data class Error(val message: String) : NetworkResult()
object Loading : NetworkResult()
How are sealed classes different from Enums and Abstract Classes?
Sealed classes have some overlap with enums and abstract classes, but they’re used for different purposes. Let’s get into that.
Enums restrict variables to represent a fixed set of constants. This is kind of similar to sealed classes, but Enums are slightly less flexible, as they can’t have complex properties and methods. And crucially, enums do not support inheritance from other classes or allow them to extend.
Enums are great when we’re only dealing with simple named values (up, down etc), or all cases are essentially related (e.g. days of the week).
Abstract classes are slightly looser than sealed classes, as they simply define a common base class. They’re more flexible than sealed classes (you can define them anywhere in the project, and they’re open to extension) but this has its drawbacks too: the compiler won’t enforce exhaustive handling with abstract classes, and they won’t give us the same type safety when modelling.
Abstract classes are great for base types, frameworks, and extensible designs.
How do we implement sealed classes?
Sealed classes in Kotlin can be defined using these simple steps:
- Create a new Kotlin file: Sealed classes have to be defined in their own file, or within the same file where all the subclasses are declared.
- Define the sealed class: Precede the class declaration with a sealed keyword.
- Define the subclasses of the sealed class: They might be data classes, regular classes or just plain objects which may sometimes contain business logic. The subclasses can define their own set of properties or methods.
// Example of Sealed class, NetworkResult.kt
sealed class NetworkResult
// Subclass for a successful network response
data class Success(val data: String) : NetworkResult()
// Subclass for an error response
data class Error(val message: String) : NetworkResult()
// Subclass for a loading state
object Loading : NetworkResult()
How to create advanced features with sealed classes
Nested sealed classes
Kotlin sealed classes can also be nested within one another, which allows us to create more complex hierarchy-type structures. This allows us to organize our states into categories, simulate user journeys and mimic real-life scenarios (in the case of payment apps, for example, we can group our classes into ‘payment success’ and ‘payment failure’ buckets).
Here’s some code to provide illustration (note that the following nested sealed class would have to follow the same rules as its outer sealed class, and all of its subclasses must be defined in this file too).
// Example showing Nested sealed classes NetworkResponse.kt
sealed class NetworkResponse {
sealed class Success : NetworkResponse() {
data class DataSuccess(val data: String) : Success()
object EmptySuccess : Success()
}
sealed class Error : NetworkResponse() {
data class NetworkError(val message: String) : Error()
data class ServerError(val code: Int, val message: String) : Error()
}
object Loading : NetworkResponse()
}
Sealed interfaces
The sealed Interface was introduced by Kotlin 1.5 and provides some of the same benefits as sealed classes, but offers additional support when combined with an interface. This is a good definition of how sealed interfaces allow us to define closed set of implementations, while enabling some form of multiple inheritance.
// Example of Sealed interface UserAction
sealed interface UserAction
data class Click(val buttonId: Int) : UserAction
data class Swipe(val direction: String) : UserAction
object Logout : UserAction()
Real-world use cases
State-based apps and sealed classes are a great way to manage the most common API response cases, where there are three types of state in every call (Success(200), Error(300/401) and Loading ). When using sealed classes, we can handle different types of API responses in a clean, typed way.
Here are some examples:
1. Sealed classes for API responses
//Example Sealed Classes for API Responses
sealed class ApiResponse<out T> {
data class Success<out T>(val data: T) : ApiResponse<T>()
data class Error(val message: String) : ApiResponse<Nothing>()
object Loading : ApiResponse<Nothing>()
}
2. Sealed classes in a repository
// Example of Sealed Classes in a Repository
class UserRepository {
suspend fun fetchUserData(): ApiResponse<User> {
return try {
// Simulate network request
val user = fetchFromNetwork()
ApiResponse.Success(user)
} catch (e: Exception) {
ApiResponse.Error(e.message ?: "Unknown error")
}
}
}
3. Handle API responses in a ViewModel
// Example of API Responses
class UserViewModel : ViewModel() {
private val _userData = MutableLiveData<ApiResponse<User>>()
val userData: LiveData<ApiResponse<User>> get() = _userData
fun loadUserData() {
viewModelScope.launch {
_userData.value = ApiResponse.Loading
_userData.value = UserRepository().fetchUserData()
}
}
}
4. Display the API Response in a UI Component
// Example API Response in a UI Component
class UserFragment : Fragment() {
private lateinit var viewModel: UserViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_user, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this).get(UserViewModel::class.java)
viewModel.userData.observe(viewLifecycleOwner, { response ->
when (response) {
is ApiResponse.Loading -> showLoading()
is ApiResponse.Success -> showUser(response.data)
is ApiResponse.Error -> showError(response.message)
}
})
viewModel.loadUserData()
}
private fun showLoading() {
// Display loading indicator
}
private fun showUser(user: User) {
// Display user data
}
private fun showError(message: String) {
// Display error message
}
}diugejgh
Best practices for using sealed classes
Make when
expressions truly exhaustive
As we outlined at the top, sealed classes allow us to enforce exhaustive when
expressions. However, we still need to put some guardrails in place to maximize the benefit:
- Always guarantee your
when
expressions handle each possible subclass of the sealed class. Kotlin will provide a compile-time warning or error if you forget one of the subclasses. - If you are not certain that all subtypes will be caught, add an
else
clause as a catch-all. This practice can be useful if it’s likely you’ll need to add new subclasses in the future.
Maintain consistency of data and immutability
Immutable data within sealed class subclasses has lots of benefits. It reduces memory overhead, avoiding the need to create a defensive copy on each assignment, and helps maintain consistency.
- When you define properties in your subclasses, mark them as
val
instead ofvar
and favor immutable data. This way, once the instance is created, its state cannot be modified. - Do not include the mutable state in your sealed classes. Simply grab the data and keep it in a form that will not change once created.
Be as efficient as possible
Though sealed classes are managed efficiently in Kotlin, a large number of subclasses can mess up things and it may slightly affect the performance as well. In fact, as in all aspects of coding, it’s important to be as clean and efficient as possible.
- Keep your subclasses to a minimum. If you don’t think a subclass is absolutely necessary, don’t write it.
- Always use optimal data structures and keep sealed class data as small as possible.
- Keep class hierarchies as shallow and well-defined as you can (deep hierarchies can make matching patterns and writing recursive algorithms significantly more difficult, which will affect the maintainability and performance of your code).
- If you have some performance-critical paths, then be sure to evaluate whether it would be beneficial for your use case to use sealed classes.
Perform those crucial last-minute checks
- Remember that when you use sealed class in the assignment operator, make sure all cases are recognized.
- Check for any performance bottlenecks that arise from sealed classes. Use profiling tools to track memory utilization and execution time and adjust where it makes sense, based on real data.
And finally… logging with sealed classes
As you’ll know by now, Sealed classes give you a closed hierarchy — you know all possible subclasses. An effective logging regime magnifies these benefits, allowing you to cover off every variant of the state/event, reveal which branch of your logic has executed, and simplify your testing and monitoring.
There are lots of ways we can achieve effective logging with Kotlin sealed class, but the simplest way is to override (baseline)
with toString()
.
sealed class Event {
data class Click(val x: Int, val y: Int) : Event()
data class KeyPress(val key: Char) : Event()
object Idle : Event()
}
Kotlin:
val e: Event = Event.Click(10, 20)
println(e) // Click(x=10, y=20)
Or, of course, you can use Bugfender. Once you’ve got it installed, you can monitor all user devices all over the world (regardless of whether you’ve got physical access), view all logs in a single dashboard and monitor devices 24/7, even when they’re not connected to the internet.
Expect The Unexpected!
Debug Faster With Bugfender