Kotlin / Reusable behavior and typed boundaries / Lesson 9
Make alternative outcomes explicit
Choose enums for uniform finite choices and sealed hierarchies for outcomes carrying different data.
On this page
Lesson goals
- Model success, rejection, and pending states without contradictory nullable fields.
- Use exhaustive when handling and understand the compatibility cost of new alternatives.
Represent one permitted state at a time
A record with success: Boolean, reservationId: String?, and error: String? admits contradictory combinations such as success with no identifier or failure with both fields absent. A sealed outcome can give each alternative the data it requires. A confirmed result carries its reservation identity, a rejection carries a reason, and a pending result carries a job identity. This moves part of the state model into types, but constructors and external decoding still need validation. Types reduce invalid combinations only to the extent that the model itself reflects the domain correctly.
Handle each known outcome explicitly
A sealed hierarchy restricts direct subclassing according to Kotlinโs module, package, and applicable platform rules. It is not simply a class that nobody has happened to extend yet. An exhaustive when expression can require handling known alternatives, helping a maintainer discover affected decision points when a new case is added. Avoid adding else automatically when complete internal handling is valuable; it can hide a newly introduced outcome behind an old fallback. External data may still contain unknown values and needs a deliberate boundary policy.
sealed interface BookingOutcome
data class Confirmed(val reservationId: String) : BookingOutcome
data class Rejected(val reason: String) : BookingOutcome
data class Pending(val jobId: String) : BookingOutcome
fun label(outcome: BookingOutcome): String = when (outcome) {
is Confirmed -> "confirmed:${outcome.reservationId}"
is Rejected -> "rejected:${outcome.reason}"
is Pending -> "pending:${outcome.jobId}"
}
fun main() {
println(label(Confirmed("r-2")))
println(label(Rejected("full")))
println(label(Pending("j-5")))
}
confirmed:r-2
rejected:full
pending:j-5
Expected lines are confirmed:r-2, rejected:full, pending:j-5. Each branch receives the data belonging to its alternative. Source is uncompiled here; it requires Kotlin tooling supporting sealed interfaces.
Use an enum when alternatives share one shape
An enum defines a finite set of instances of one enum class, suitable for a small uniform choice such as display language or sort direction. Constants can have properties and behavior. Do not persist ordinal as a durable business identifier because reordering constants changes that numeric position. Prefer an explicit stable code and validate unknown external codes. An enum also does not enforce allowed transitions: a status property can jump from cancelled to confirmed unless the owning behavior prevents it. Separate the set of possible labels from the state machine that controls movement between them.
Translate external codes at the boundary
The example stores a stable code explicitly and parses with a nullable lookup instead of valueOf, which throws when a name is unknown. This is useful when external input can include an unsupported code. The display label and source constant name need not be the persisted wire code. If compatibility requires unknown values to survive round trips, a sealed wrapper with an unknown-code case can be more suitable than returning null. Choose that policy deliberately rather than losing information accidentally during parsing.
enum class SortDirection(val code: String) {
ASCENDING("asc"), DESCENDING("desc")
}
fun parseDirection(code: String): SortDirection? =
SortDirection.entries.firstOrNull { it.code == code }
fun main() {
println(parseDirection("asc")?.name)
println(parseDirection("other") ?: "unsupported")
}
ASCENDING
unsupported
Expected output is ASCENDING and unsupported. entries requires a Kotlin version that provides enum entries; a project must use its own declared compatible toolchain. The program was not compiled here.
Changing alternatives changes consumers
Adding a sealed subtype can force source consumers to update exhaustive when expressions, which is often helpful inside one coordinated module. Public binary compatibility and serialization are separate concerns and depend on the actual tooling and protocol. Adding an enum value can similarly surprise clients that assume a closed set. Use compatible wire evolution and mixed-version tests when applications release independently. A rich internal type should not be serialized automatically as a public contract without deciding stable field names, unknown handling, and meaning for each outcome.
Common errors and fixes
A success record contains no success data.
- Why it happens
- Independent nullable fields permit contradictory states.
- How to fix it
- Model alternatives with the required data in each case.
- Prevent it next time
- Review the combinations the type allows, not only intended examples.
Stored enum numbers change meaning after reordering.
- Why it happens
- Ordinal was used as a durable external code.
- How to fix it
- Introduce explicit stable codes with a controlled migration.
- Prevent it next time
- Keep display order separate from identity.
A broad else hides a newly added outcome.
- Why it happens
- The handler discarded useful exhaustiveness checking.
- How to fix it
- Handle internal alternatives explicitly where appropriate.
- Prevent it next time
- Review all outcome consumers when the model evolves.
Check your understanding
1. Would an enum with one nullable payload for every status improve the original contradictory record?
Show worked solution
It can name the status but still permits missing or inappropriate payloads. A sealed alternative gives confirmed a required reservation identity and rejected a required reason without making every field optional. Use an enum for uniform choices and a richer hierarchy when cases have different data requirements.
2. Why can an internally exhaustive model still need unknown handling at an API boundary?
Show worked solution
The external producer may run a newer version, send malformed data, or use a different code set. Validate and translate that input into the internal hierarchy under an explicit compatibility policy. Compiler knowledge of current local subclasses does not validate remote bytes or coordinate independent releases.
Key takeaways
- Types can exclude contradictory states when alternatives carry their own required data.
- Internal exhaustiveness and external compatibility need separate designs.