You have an enum where entries are sprouting nullable fields to hold case-specific data. How do you convert it to a sealed hierarchy, and what do you gain and lose?
answer
- nullable fields on enum => convert to sealed
- no-data case => data object; data case => data class
- gain: per-case non-null fields + smart casts
- lose: entries/valueOf/ordinal/name/EnumMap
- serialization: enum=by name free, sealed=explicit polymorphic
basics
~20 sTurn each enum entry into its own subclass of a sealed type. Cases that carry data become data classes; data-free cases become objects. You gain per-case fields and type-safe access; you lose enum freebies like ordinal, valueOf, and entries.
solid answer
~50 sWhen enum entries diverge — some need extra fields, forcing nullable properties — replace the enum with a `sealed interface` (or `sealed class`) and make each former entry a subtype. A case with no data becomes a `data object` or `object` (singleton, like the old entry); a case with payload becomes a `data class` carrying exactly the properties it needs. You gain: each case exposes only its own non-null fields, smart-casts in an exhaustive `when` give type-safe access, and you can have many instances of payload cases. You lose enum built-ins: `entries`/`values()`, `valueOf(String)`, `ordinal`, automatic `name`, and the cheap `EnumMap`/`EnumSet`. If you relied on name-based serialization or stable ordinals (DB columns, wire format), you must reintroduce that explicitly (e.g. a sealed type with a custom serializer or a discriminator). Keep singletons as `data object` so equality/`toString`/`hashCode` behave nicely.
code
kotlin · 16 lines// Before (smelly nullable enum)
enum class Event(val code: Int?, val retryAfter: Long?) {
OK(200, null), RATE_LIMITED(429, 60), DISCONNECTED(null, null)
}
// After (sealed)
sealed interface Event {
data class Ok(val code: Int) : Event
data class RateLimited(val code: Int, val retryAfter: Long) : Event
data object Disconnected : Event
}
fun delay(e: Event): Long = when (e) {
is Event.RateLimited -> e.retryAfter // smart-cast, non-null
is Event.Ok, Event.Disconnected -> 0
}go deeper
Can mechanically map each entry to a subtype and knows data object vs data class for no-data vs data cases.
Explains the gains (non-null per-case fields, smart casts) and the lost enum freebies (entries/valueOf/ordinal) and how to replace them.
Addresses serialization/wire-format and persistence impact, choosing sealed interface vs class and reintroducing discriminators where ordinals/names were relied on.
Weighs migration cost across a public API or DB, designs the polymorphic serialization/discriminator contract, and decides when divergence justifies the breaking change.
## The trigger You start with an enum like: ```kotlin enum class Event(val httpCode: Int?, val retryAfter: Long?) { OK(200, null), RATE_LIMITED(429, 60), DISCONNECTED(null, null) } ``` Notice the nullable fields: `OK` doesn't need `retryAfter`, `DISCONNECTED` needs neither code. Nullable-everywhere is the classic smell that the cases have **different shapes** — an enum is the wrong tool. ## The conversion Lift each entry into a subtype of a `sealed interface`: ```kotlin sealed interface Event { data class Ok(val httpCode: Int) : Event data class RateLimited(val httpCode: Int, val retryAfter: Long) : Event data object Disconnected : Event } ``` Rules of thumb: - **No data** => `data object` (or plain `object`). It stays a singleton, exactly like an enum entry. `data object` adds a readable `toString()` and proper `equals`/`hashCode`. - **Has data** => `data class` with ONLY the fields that case actually needs — no more nullables. - Use `sealed interface` when you don't need shared state/constructor logic; `sealed class` if you want shared properties or a non-trivial common constructor. ## What you gain - **Honest types:** `RateLimited.retryAfter` is non-null `Long`; you can't read `retryAfter` on `Ok` at all — the compiler stops you. - **Smart casts:** inside `when (e) { is Event.RateLimited -> e.retryAfter }` the compiler narrows the type and you access case fields directly. - **Many instances:** payload cases are normal classes, so different `Ok(200)` and `Ok(404)` coexist. - **Exhaustiveness preserved:** `when` over the sealed type is still checked at compile time. ## What you lose (and must replace) - `Event.entries` / `values()` — no automatic list of cases. Re-create with `Event::class.sealedSubclasses` (reflection) or a hand-written `listOf(...)` of the objects. - `valueOf("OK")` — gone; build your own `fromName` if needed. - `ordinal` and automatic `name` — gone; add an explicit discriminator property if you serialize. - `EnumMap`/`EnumSet` performance — use regular maps/sets. - **Serialization stability:** enums serialize by `name` for free; sealed hierarchies need an explicit polymorphic serializer (e.g. kotlinx.serialization `@Serializable` with a sealed parent and class discriminator). ## When NOT to convert If every case truly has the same shape and you rely on `ordinal`/`valueOf`/`EnumMap`, keep the enum. Convert only when divergence (nullable/unused fields, varied payload, need for many instances) shows up.
- How do you recover an 'all cases' list after dropping the enum?For object-only hierarchies, hand-write listOf(...) of the data objects, or use the sealed type's reflective `sealedSubclasses`. There's no free entries property.
- Why prefer data object over plain object for the no-data case?data object generates a readable toString() ("Disconnected") and stable equals/hashCode, matching the ergonomics enum entries gave you.
saying these in an interview costs you the question
- Converting but keeping nullable fields instead of giving each case only its own data
- Forgetting that valueOf/ordinal/entries disappear and break serialization
- Making payload cases objects (singletons) when they need many instances
- Assuming kotlinx serialization handles sealed polymorphism with zero configuration