Bonus: From Java to Kotlin – Part XII: Sealed Classes vs Enums / Polymorphism
Considering a move to Kotlin? Coming from a Java background? In this short series of blog posts, I’ll take a look at familiar, straightforward Java concepts and demonstrate how you can approach them in Kotlin. While many of these points have already been discussed in earlier posts by colleagues, my focus is simple: how you used to do it in Java, and how you do it in Kotlin.
Case 12: In this post, we explore how to model a finite set of types in Java — the “old way” with enums or class hierarchies, the “new way” with Java SE 17+ sealed classes — and then see how Kotlin handles them elegantly with sealed classes.
The problem
Suppose you have a concept with a finite number of types, like a payment status:
-
Pending
-
Completed
-
Failed
You want to be able to:
-
Represent the different types
-
Optionally store extra data per type
-
Handle each type safely and exhaustively
In other words: you want your program to know whether your money has arrived, is fashionably late, or vanished into the void — all without having to play detective in instanceof checks.
Java 'the old way': Enums or Class Hierarchies
Using Enums
public enum PaymentStatus {
PENDING,
COMPLETED,
FAILED
}
Usage:
PaymentStatus status = PaymentStatus.PENDING;
switch (status) {
case PENDING:
System.out.println("Pending...");
break;
case COMPLETED:
System.out.println("Completed!");
break;
case FAILED:
System.out.println("Failed!");
break;
}
✅ Works for simple states
❌ Cannot store per-case extra data easily
❌ Cannot attach behavior without extra boilerplate
Using Class Hierarchies
abstract class PaymentStatus {}
class Pending extends PaymentStatus {}
class Completed extends PaymentStatus {
private final LocalDateTime completedAt;
Completed(LocalDateTime completedAt) { this.completedAt = completedAt; }
}
class Failed extends PaymentStatus {
private final String reason;
Failed(String reason) { this.reason = reason; }
}
Usage:
PaymentStatus status = new Completed(LocalDateTime.now());
if (status instanceof Completed c) {
System.out.println("Completed at " + c.completedAt);
} else if (status instanceof Failed f) {
System.out.println("Failed because " + f.reason);
}
✅ Can store extra data
❌ Boilerplate + instanceof checks
❌ No compile-time guarantee that all cases are handled
Java 'the new way': Sealed Classes with Exhaustive Switch (Java 21+)
Java SE 17 introduced sealed classes (JEP 409), allowing a class or interface to define which subtypes are permitted.
With Java 21, enhanced switch statements and pattern matching make it possible for the compiler to enforce exhaustive handling of all sealed subtypes — similar to Kotlin’s when.
public abstract sealed class PaymentStatus
permits Pending, Completed, Failed { }
public final class Pending extends PaymentStatus { }
public final class Completed extends PaymentStatus {
private final LocalDateTime completedAt;
public Completed(LocalDateTime completedAt) { this.completedAt = completedAt; }
}
public final class Failed extends PaymentStatus {
private final String reason;
public Failed(String reason) { this.reason = reason; }
}
Using an exhaustive switch:
PaymentStatus status = new Completed(LocalDateTime.now());
switch (status) {
case Pending p -> System.out.println("Pending...");
case Completed c -> System.out.println("Completed at " + c.completedAt);
case Failed f -> System.out.println("Failed because " + f.reason);
}
✅ The compiler ensures that all subtypes of PaymentStatus are handled
✅ No default branch needed
✅ Safe and concise, similar to Kotlin’s when
Note: The older switch syntax without pattern matching still requires a default and does not enforce exhaustiveness.
The Kotlin way: Sealed Classes
Kotlin’s sealed classes work similarly to Java’s sealed classes in that the compiler knows all possible subtypes.
However, unlike Java, you do not need to explicitly list the permitted subclasses — all subclasses must be declared in the same file.
This automatically keeps the class hierarchy "closed" and allows the compiler to enforce exhaustive when expressions.
-
Finite set of subtypes
-
Optional data per subtype
-
Exhaustive
whenexpressions -
Concise syntax with minimal boilerplate
sealed class PaymentStatus
data object Pending : PaymentStatus()
data class Completed(val completedAt: LocalDateTime) : PaymentStatus()
data class Failed(val reason: String) : PaymentStatus()
Usage:
val status: PaymentStatus = Completed(LocalDateTime.now())
when (status) {
is Pending -> println("Pending...")
is Completed -> println("Completed at ${status.completedAt}")
is Failed -> println("Failed because ${status.reason}")
}
✅ Compiler ensures all cases are handled
✅ No instanceof boilerplate
✅ Each subtype can hold its own data
Polymorphism without when
Sealed classes can also encapsulate behavior:
sealed class PaymentStatus {
abstract fun message(): String
}
data object Pending : PaymentStatus() {
override fun message() = "Pending..."
}
data class Completed(val completedAt: LocalDateTime) : PaymentStatus() {
override fun message() = "Completed at $completedAt"
}
data class Failed(val reason: String) : PaymentStatus() {
override fun message() = "Failed because $reason"
}
val status: PaymentStatus = Completed(LocalDateTime.now())
println(status.message())
✅ Cleaner polymorphic code
✅ Each subtype contains its own behavior
Short catch-up: What is Polymorphism?
Polymorphism is the ability of a type to take multiple forms. In practice, it means you can write code that works on a general type (like a superclass or interface) but behaves differently depending on the specific subtype at runtime.
For example, with a sealed PaymentStatus, you can handle Pending, Completed, or Failed
in the same switch or when block, but each subtype can have its own data and behavior.
Enum vs Sealed Class vs Data Class
| Feature | Enum | Java Sealed (JDK 21+) | Kotlin Sealed |
|---|---|---|---|
Finite set of types |
✅ |
✅ |
✅ |
Extra data per type |
❌ (tricky) |
✅ |
✅ |
Custom behavior per type |
❌ |
✅ (requires methods / boilerplate) |
✅ (clean) |
Exhaustive checks |
✅ (switch) |
✅ (with enhanced switch / pattern matching) |
✅ (compiler enforced) |
Boilerplate |
Low |
Medium |
Low |
-
Enum → simple flags
-
Data class → flat objects
-
Sealed class → hierarchies with data and behavior
Takeaway
-
Java “old way”: enums or class hierarchies → works, but often verbose
-
Java “new way”: sealed classes → controlled hierarchies, safer, closer to Kotlin
-
Kotlin sealed classes → expressive, concise, compile-time safe, and polymorphic
✅ More flexibility without losing the benefits of sealed classes. ✅ Still fully compile-time safe with exhaustive Rule of thumb
|