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?
answer
- enum: serialize by name free; ordinal is brittle
- sealed: polymorphic serialization + discriminator setup
- add enum constant: non-exhaustive when survives; runtime unknown trap
- add sealed subtype: compile-break on every exhaustive when
- neither open to external subtypes
basics
~20 sEnums 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 sChoosing 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
Knows enums serialize by name and sealed types carry varied data, but may not weigh evolution or wire concerns.
Identifies the serialization difference and the exhaustive-when compile-break when adding a sealed subtype.
Balances all four axes — shape, serialization mechanics, source/binary evolution, openness — and prescribes name-based enum serialization with unknown fallback vs. discriminated sealed polymorphism.
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