skip to content

For a public API value type with a small fixed set of cases, what trade-offs decide between an enum and a sealed type, especially around serialization and evolution?

level: seniorimportance: should knowfreq 40%

answer

  1. enum: serialize by name free; ordinal is brittle
  2. sealed: polymorphic serialization + discriminator setup
  3. add enum constant: non-exhaustive when survives; runtime unknown trap
  4. add sealed subtype: compile-break on every exhaustive when
  5. neither open to external subtypes

basics

~20 s

Enums serialize simply by name and are easy to evolve by adding constants, but every case must share one shape. Sealed types let cases carry different data and add type-safety, but need explicit polymorphic serialization and are harder to extend across module boundaries.

solid answer

~50 s

Choosing for a public type weighs four axes. **Shape:** if all cases are identical constants, enum; if cases carry distinct payloads, sealed. **Serialization:** enums serialize/deserialize by `name` essentially for free (kotlinx, Jackson, JSON), and you can map ordinals — but ordinal is brittle if you reorder. Sealed types need explicit *polymorphic* serialization: a `@Serializable` sealed parent with a class discriminator, or registered subtypes; more setup and a discriminator field on the wire. **Evolution:** adding an enum constant is source/binary friendlier; consumers with non-exhaustive `when` still compile (with a warning) — though new constants can surprise old code. Adding a sealed subtype is a compile-time break for every exhaustive `when`, which is good for internal correctness but bad across a published library boundary. **Consumer extensibility:** neither is open by default; sealed cases are closed to the module/package. For cross-process contracts, prefer enum for flag-like values and sealed for structured variants where you control both ends.

go deeper

for a junior

Knows enums serialize by name and sealed types carry varied data, but may not weigh evolution or wire concerns.

for a middle

Identifies the serialization difference and the exhaustive-when compile-break when adding a sealed subtype.

for a senior

Balances all four axes — shape, serialization mechanics, source/binary evolution, openness — and prescribes name-based enum serialization with unknown fallback vs. discriminated sealed polymorphism.

for a principal

Frames the choice as a contract/governance decision: who owns consumers, forward/backward compatibility strategy, discriminator stability, and the cost of compiler-forced refactors versus runtime unknowns.

## The decision frame For a public value type, four trade-offs dominate. ### 1. Case shape - **Enum:** all cases identical in shape; ideal for flags, statuses, modes (`Currency`, `OrderStatus`). - **Sealed:** cases carry different data (`ApiError.NotFound(id)` vs `ApiError.RateLimited(retryAfter)`). Choose sealed when payload varies. ### 2. Serialization - **Enum:** serializes by `name` out of the box in kotlinx.serialization, Jackson, Moshi, etc. Stable as long as you don't rename constants. `ordinal`-based serialization is compact but **fragile** — reordering or inserting a constant silently shifts every ordinal, corrupting stored data. Prefer name-based. - **Sealed:** requires **polymorphic serialization**. With kotlinx.serialization you annotate the sealed parent and each subtype `@Serializable`; the format writes a `type` discriminator (configurable via `classDiscriminator`). Jackson needs `@JsonTypeInfo`/`@JsonSubTypes`. More configuration, a discriminator on the wire, and subtype registration to maintain. ```kotlin @Serializable sealed interface ApiError { @Serializable @SerialName("not_found") data class NotFound(val id: String) : ApiError @Serializable @SerialName("rate_limited") data class RateLimited(val retryAfter: Long) : ApiError } ``` ### 3. Evolution / compatibility - **Adding an enum constant:** old consumers using a non-exhaustive `when` (with `else`) keep compiling; exhaustive `when` without `else` becomes a compile error you must update. At runtime, deserializing an *unknown* constant fails unless you handle it (kotlinx `@JsonNames`/unknown handling, or a catch-all `UNKNOWN`). This is the classic forward-compat trap. - **Adding a sealed subtype:** breaks every exhaustive `when` at compile time — excellent for an internal codebase (the compiler lists every spot to update), but a **breaking change** for a published library because downstream `when`s won't compile. ### 4. Extensibility / openness Neither is open to arbitrary external subtypes: enum is closed by definition; sealed subtypes must live in the same module (same package for sealed classes). If third parties must add cases, neither fits — you'd need an open interface (losing exhaustiveness). ## Practical guidance - Cross-process/persisted flag with identical cases => **enum**, serialized by name, with an unknown-value fallback for forward compatibility. - Structured variants you control on both ends (internal modules, your own services) => **sealed** with polymorphic serialization and explicit `@SerialName` discriminators for wire stability. - Avoid `ordinal`-based persistence; never reorder enum constants that have been serialized. ## Subtle point Exhaustiveness is a double-edged sword: a strength internally (compiler-driven refactors), a liability across a public boundary (forces recompilation of every consumer). Decide based on who owns the consumers.

  • Why avoid ordinal-based enum serialization?
    Ordinal is positional; inserting or reordering constants silently remaps stored values, corrupting persisted data. Serialize by name instead.
  • Why can adding a sealed subtype be worse for a library than for an app?
    It breaks every exhaustive when in downstream code at compile time — fine when you own all callers, a breaking change when external consumers do.
  • How do you make enum deserialization forward-compatible?
    Provide a catch-all/UNKNOWN constant or configure the framework to map unknown names to a default rather than throwing.

saying these in an interview costs you the question

  • Recommending ordinal-based serialization for persisted enums
  • Claiming sealed types serialize polymorphically with zero configuration
  • Ignoring the runtime unknown-constant failure when new enum values arrive
  • Saying adding a sealed subtype is always safe across a public API
  • Believing either construct lets external modules add new cases

context