Sealed Classes, Enums, Objects & Companion Objects
Modern Kotlin state modeling patterns for clean, type-safe Android architecture
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Sealed classes model bounded type hierarchies where every possible state is known at compile time. Enums model fixed constant sets. Objects are thread-safe singletons. Companion objects provide class-level factory methods and constants. Together, these form the building blocks of type-safe state management in MVVM and MVI architectures.
Real-world relevance
In a real-time SaaS collaboration app, UiState sealed class (Loading/Success/Error) drives the UI layer. NetworkResult sealed class wraps API responses. The enum class SyncStatus tracks document sync state. A companion object factory method creates the ViewModel with proper dependencies. The object SocketManager is the app-wide singleton managing the WebSocket connection.
Key points
- sealed class for state modeling — sealed class UiState restricts all subclasses to the same compilation unit. The compiler can verify exhaustive when expressions. Perfect for: Loading/Success/Error states, navigation events, network results. Each subclass can carry different data.
- sealed interface (Kotlin 1.5+) — sealed interface allows subclasses to be data classes or implement other interfaces — more flexible than sealed class. Use sealed interface when subclasses need to extend different base classes. Sealed classes and interfaces are the modern alternative to raw enum+data approaches.
- enum class basics — enum class Status { PENDING, APPROVED, REJECTED } creates a fixed set of constants with automatic name() and ordinal. Enums can implement interfaces. ordinal is fragile for persistence — use name or a custom code field instead.
- enum with properties and methods — enum class Priority(val level: Int, val label: String) { HIGH(1,'High'), MEDIUM(2,'Medium'), LOW(3,'Low') }. Enum entries can override abstract methods. Enums are singletons — you cannot create new instances.
- sealed class vs enum — Enums: fixed constants, all instances have the same type shape. Sealed classes: each subclass can have different fields and constructors. Use enum for fixed constants, use sealed class when different states carry different payloads (Success carries data, Error carries exception).
- object declaration (singleton) — object AppConfig { val BASE_URL = '...' } is a thread-safe, lazily initialized singleton. The JVM generates it as a class with a static INSTANCE field. Use for app-wide constants, service locators (with caution), or stateless utility objects.
- companion object — companion object { } inside a class is the Kotlin equivalent of Java static members. factory fun create(): MyClass is the idiomatic factory method pattern. Companion objects can implement interfaces and are actual objects, not just static scopes.
- object expression (anonymous object) — val listener = object : View.OnClickListener { override fun onClick(v: View) { } } is an anonymous class instance. Each creation makes a new object. In Kotlin, prefer lambdas for SAM (Single Abstract Method) interfaces, but object expressions for multi-method interfaces.
- when exhaustiveness with sealed classes — val result: UiState = ... ; when(result) { is Loading -> ; is Success -> result.data ; is Error -> result.exception } — compiler REQUIRES all subclasses to be handled when when is an expression. Removes need for else branch.
- Nesting sealed classes — Sealed classes can be nested: sealed class NetworkResult { data class Success(val data: T) : NetworkResult() ; data class Error(val code: Int, val message: String) : NetworkResult() ; object Loading : NetworkResult() }. The Loading singleton needs no data.
- companion object @JvmStatic for Java interop — companion object { @JvmStatic fun create(): MyClass } makes the factory method callable as MyClass.create() from Java (without .Companion). Required when your Kotlin code is called from Java, including some Android framework callbacks.
- Enum serialization pitfall — Never persist enum ordinal — if you add/reorder enum entries, existing persisted data breaks. Always persist enum.name (String) or use a custom serialized value. With Gson/Moshi/kotlinx.serialization, configure enum serialization explicitly.
Code example
// Sealed class for UI state — the standard pattern
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>()
}
// Sealed interface (Kotlin 1.5+) — more flexible
sealed interface DocumentEvent {
data class Opened(val docId: String, val userId: String) : DocumentEvent
data class Edited(val docId: String, val delta: Delta) : DocumentEvent
object Closed : DocumentEvent
}
// Enum with properties — avoid ordinal for persistence
enum class SyncStatus(val code: String, val isTerminal: Boolean) {
PENDING("pending", false),
IN_PROGRESS("in_progress", false),
SYNCED("synced", true),
FAILED("failed", true);
companion object {
fun fromCode(code: String): SyncStatus =
values().firstOrNull { it.code == code } ?: PENDING
}
}
// object singleton — WebSocket manager
object SocketManager {
private var socket: WebSocket? = null
fun connect(url: String) { /* ... */ }
fun disconnect() { socket?.close(1000, "User disconnected") }
}
// companion object factory
class DocumentRepository private constructor(
private val api: DocumentApi,
private val db: DocumentDao
) {
companion object {
fun create(api: DocumentApi, db: DocumentDao) =
DocumentRepository(api, db)
}
}
// Exhaustive when — compiler enforces all cases
fun renderUiState(state: UiState<List<Document>>) = when (state) {
is UiState.Loading -> showShimmer()
is UiState.Success -> showDocuments(state.data)
is UiState.Error -> showError(state.message)
}Line-by-line walkthrough
- 1. sealed class UiState — 'out' makes it covariant; UiState can be used as UiState
- 2. object Loading : UiState() — singleton object; Nothing is Kotlin's bottom type, compatible with any UiState
- 3. data class Success(val data: T) — each Success carries actual data; type-safe, no casting needed
- 4. sealed interface DocumentEvent — allows Opened/Edited to be data classes, Closed to be object; more flexible than sealed class
- 5. enum class SyncStatus(val code: String, val isTerminal: Boolean) — each entry has typed properties; use code for persistence, not ordinal
- 6. companion object { fun fromCode() } inside enum — factory method on the enum itself; idiomatic Kotlin lookup pattern
- 7. object SocketManager — thread-safe singleton; initialized once by JVM class loading; accessible everywhere as SocketManager.connect()
- 8. class DocumentRepository private constructor() — private constructor forces use of factory method
- 9. companion object { fun create() } — factory method; allows validation before construction; testable by swapping implementation
- 10. when(state) { is Loading -> ; is Success -> state.data } — no else needed; compiler verifies all sealed subclasses are handled
Spot the bug
sealed class ApiResult {
data class Success(val data: String) : ApiResult()
data class Error(val message: String) : ApiResult()
}
fun handleResult(result: ApiResult) {
when (result) {
is ApiResult.Success -> println(result.data)
// Missing Error case
}
}
enum class Direction { NORTH, SOUTH, EAST, WEST }
fun saveDirection(dir: Direction) {
database.save("direction", dir.ordinal) // bug here
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Sealed Classes and Interfaces (kotlinlang.org)
- Enum Classes in Kotlin (kotlinlang.org)
- Object Declarations and Companion Objects (kotlinlang.org)
- UI State Management in Android (developer.android.com)