How sealed interfaces and classes in Java 17 enable exhaustive pattern matching, replace string constants and enums for complex state hierarchies, and make invalid states unrepresentable.
Sealed interfaces, finalised in Java 17, allow you to declare exactly which classes may implement or extend a type. The compiler knows the complete set of subtypes. This makes the type a closed hierarchy — useful when the domain has a fixed set of cases, and powerful when combined with pattern matching, because the compiler can verify you have handled every case.
// Anyone can implement this — the set of cases is unknown
public interface OrderStatus {}
public class Pending implements OrderStatus {}
public class Active implements OrderStatus {}
public class Filled implements OrderStatus {}
public class Cancelled implements OrderStatus {}
When you switch on OrderStatus, the compiler cannot tell you if you missed a case. Pattern matching with instanceof chains is verbose and a new subclass anywhere in the codebase silently breaks exhaustiveness.
public sealed interface OrderStatus
permits Pending, Active, Filled, Cancelled, Voided {}
public record Pending(Instant createdAt) implements OrderStatus {}
public record Active(Instant acceptedAt, double price) implements OrderStatus {}
public record Filled(Instant filledAt, double executedPrice, double size) implements OrderStatus {}
public record Cancelled(Instant cancelledAt, String reason) implements OrderStatus {}
public record Voided(Instant voidedAt) implements OrderStatus {}
permits names every permitted subtype. Adding a new subtype without updating permits fails to compile. The hierarchy is closed.
With a sealed type, switch expressions can be exhaustive without a default clause — the compiler knows all cases:
public String describe(OrderStatus status) {
return switch (status) {
case Pending p -> "Pending since " + p.createdAt();
case Active a -> "Active at price " + a.price();
case Filled f -> "Filled at " + f.executedPrice() + " for " + f.size();
case Cancelled c -> "Cancelled: " + c.reason();
case Voided v -> "Voided at " + v.voidedAt();
};
}
If you add Expired to the permits list, every switch on OrderStatus in your codebase fails to compile until you add the Expired case. The compiler becomes your exhaustiveness checker.
Pattern matching in switch supports when guards:
public BigDecimal computeLiability(OrderStatus status, BigDecimal stake) {
return switch (status) {
case Active a when a.price() > 10.0 ->
stake.multiply(BigDecimal.valueOf(a.price() - 1.0));
case Active a ->
stake.multiply(BigDecimal.valueOf(a.price() - 1.0).multiply(BigDecimal.valueOf(0.9)));
case Filled f ->
BigDecimal.valueOf(f.executedPrice() - 1.0).multiply(BigDecimal.valueOf(f.size()));
default ->
BigDecimal.ZERO;
};
}
Guards allow splitting a single case into sub-cases based on field values — finer control than a simple type match.
Sealed types are not limited to interfaces:
public sealed abstract class MarketEvent
permits MarketEvent.Opened, MarketEvent.Suspended,
MarketEvent.Resumed, MarketEvent.RunnerRemoved, MarketEvent.Closed {
public abstract Instant occurredAt();
public record Opened(Instant occurredAt) extends MarketEvent {}
public record Suspended(Instant occurredAt, String reason) extends MarketEvent {}
public record Resumed(Instant occurredAt) extends MarketEvent {}
public record RunnerRemoved(Instant occurredAt, long selectionId, double reductionFactor)
extends MarketEvent {}
public record Closed(Instant occurredAt) extends MarketEvent {}
}
Inner record types as subtypes is a clean pattern — everything lives in one file and the hierarchy is self-documenting.
Enums are appropriate for simple values without associated data. When each case carries different data, a sealed interface is the right tool:
// Enum forces all cases to carry the same structure — awkward
public enum OrderResult {
SUCCESS,
FAILURE,
PARTIAL_FILL
}
// Sealed type: each case carries what it needs
public sealed interface OrderResult
permits OrderResult.Success, OrderResult.Failure, OrderResult.PartialFill {
record Success(String orderId, double executedPrice) implements OrderResult {}
record Failure(String errorCode, String message) implements OrderResult {}
record PartialFill(String orderId, double sizeMatched, double sizeRemaining)
implements OrderResult {}
}
Usage:
OrderResult result = betfairClient.placeOrder(request);
switch (result) {
case OrderResult.Success s -> log.info("Placed: {}", s.orderId());
case OrderResult.Failure f -> log.error("Failed: {}", f.message());
case OrderResult.PartialFill pf -> handlePartialFill(pf);
}
The strongest design principle enabled by sealed types: if your type system only permits valid states, invalid states cannot be expressed in code.
// Old: status as String — any string is valid, including "PENIDNG" (typo)
public class Order {
private String status;
}
// New: only declared states are valid
public class Order {
private final OrderStatus status; // sealed type — compiler enforces membership
}
The compiler catches the typo. The reviewer does not need to check for it. The test suite does not need to cover it.
Sealed interfaces are the most powerful addition to Java’s type system in years. Combined with records and pattern matching, they bring to Java the algebraic data type expressiveness that has long been a strength of functional languages.
If you’re designing Java domain models and want a review of type safety patterns, get in touch.