skip to content

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?

level: middleimportance: should knowfreq 60%

answer

  1. nullable fields on enum => convert to sealed
  2. no-data case => data object; data case => data class
  3. gain: per-case non-null fields + smart casts
  4. lose: entries/valueOf/ordinal/name/EnumMap
  5. serialization: enum=by name free, sealed=explicit polymorphic

basics

~20 s

Turn 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 s

When 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
kotlin
// 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

for a junior

Can mechanically map each entry to a subtype and knows data object vs data class for no-data vs data cases.

for a middle

Explains the gains (non-null per-case fields, smart casts) and the lost enum freebies (entries/valueOf/ordinal) and how to replace them.

for a senior

Addresses serialization/wire-format and persistence impact, choosing sealed interface vs class and reintroducing discriminators where ordinals/names were relied on.

for a principal

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

context