Sealed Interfaces and Classes
Close a type hierarchy with sealed and permits so the compiler knows every case, and model results, events and errors with records.
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
final class Square implements Shape { }A sealed class or interface names the only types allowed to extend or implement it. It has been a standard feature since Java 17.
sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String transactionId, long amountPence) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(String redirectUrl) implements PaymentResult {}
Each permitted subtype must say how the hierarchy continues: final (no further subtypes), sealed (its own closed list) or non-sealed (open again to anyone). Records are implicitly final, and so is an enum (or implicitly sealed, when a constant has its own body), so neither needs a modifier. That is why a sealed interface implemented by records is such a common pairing. Permitted types must be in the same module, or in the same package when you are not using modules. If they are all declared in the same source file you can leave out permits and the compiler infers the list.
Why close a hierarchy
An enum gives you a fixed set of values that all have the same shape. A sealed interface gives you a fixed set of cases that each carry different data: an approved payment has a transaction id, a declined one has a reason, a pending one has a redirect URL. That models outcomes far better than one class with a status field and three nullable fields, where nothing stops you reading the transaction id of a declined payment.
The bigger payoff comes with switch. Because the compiler knows every subtype, a switch over a sealed type that handles each one needs no default. When someone adds a fourth result, every such switch stops compiling, so the compiler hands you a to-do list instead of a production bug.
Where it fits in a service
- Results of operations that fail in expected ways, without using exceptions for control flow.
- Domain events and commands passed through a queue.
- API error types that are mapped to HTTP statuses in one place.
- Webhook message variants, once the JSON has been parsed into the right record.
Things to watch
non-sealed reopens the hierarchy below that point. A switch can then only be exhaustive over that branch by having a case for the non-sealed type itself. Use it deliberately, for example as an extension point.
Sealing makes adding a subtype a breaking change for every exhaustive switch. Inside one service that is exactly what you want. For a library other teams compile against, decide whether callers should be forced to handle new cases.
Sealing does not tell a JSON library how to tell the variants apart. You still configure a type property, such as "type": "approved", for deserialisation to pick the right record.
Example
When to use it
- Return a sealed PaymentResult from a payment gateway client so the controller must handle approved, declined and pending outcomes.
- Model the events of an order as records under a sealed interface and rebuild the order's state by replaying them.
- Represent configuration parsing as Ok or Err so startup can report every bad setting before the service listens.
More examples
final, non-sealed and a sealed abstract class
Sealing works for abstract classes too, which suits a hierarchy with shared state or methods. SilentPush compiles only because its parent is non-sealed; extending EmailNotification would be a compile error.
sealed abstract class Notification permits EmailNotification, SmsNotification, PushNotification {
abstract String recipient();
}
final class EmailNotification extends Notification {
String recipient() {
return "[email protected]";
}
}
final class SmsNotification extends Notification {
String recipient() {
return "+44 7700 900123";
}
}
non-sealed class PushNotification extends Notification {
String recipient() {
return "device-42";
}
}
// Allowed: PushNotification is non-sealed, so anyone may extend it.
class SilentPush extends PushNotification {}
void main() {
IO.println(Notification.class.isSealed());
IO.println(PushNotification.class.isSealed());
List<Notification> all = List.of(new EmailNotification(), new SmsNotification(), new SilentPush());
for (Notification n : all) {
IO.println(n.getClass().getSimpleName() + " -> " + n.recipient());
}
}
// Output:
// true
// false
// EmailNotification -> [email protected]
// SmsNotification -> +44 7700 900123
// SilentPush -> device-42Domain events folded into a view
All the types are in one file, so permits is inferred. Adding a RefundIssued event would make apply fail to compile until it handles refunds, which is the safety net an event-driven service needs.
sealed interface OrderEvent {}
record OrderPlaced(String orderId, long totalPence) implements OrderEvent {}
record PaymentReceived(String orderId, long amountPence) implements OrderEvent {}
record OrderShipped(String orderId, String carrier) implements OrderEvent {}
record OrderCancelled(String orderId, String reason) implements OrderEvent {}
record OrderView(String status, long paidPence) {}
OrderView apply(OrderView view, OrderEvent event) {
return switch (event) {
case OrderPlaced p -> new OrderView("placed", 0);
case PaymentReceived p -> new OrderView("paid", view.paidPence() + p.amountPence());
case OrderShipped s -> new OrderView("shipped via " + s.carrier(), view.paidPence());
case OrderCancelled c -> new OrderView("cancelled: " + c.reason(), view.paidPence());
};
}
void main() {
List<OrderEvent> history = List.of(
new OrderPlaced("o-1", 4500),
new PaymentReceived("o-1", 4500),
new OrderShipped("o-1", "Royal Mail"));
OrderView view = new OrderView("new", 0);
for (OrderEvent e : history) {
view = apply(view, e);
IO.println(view);
}
}
// Output:
// OrderView[status=placed, paidPence=0]
// OrderView[status=paid, paidPence=4500]
// OrderView[status=shipped via Royal Mail, paidPence=4500]A generic Result type
A sealed Result makes the failure case part of the method's type, so callers cannot forget it. Use this for expected failures such as bad configuration or input, and keep exceptions for the unexpected.
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message) implements Result<T> {}
Result<Integer> parsePort(String raw) {
try {
int port = Integer.parseInt(raw.strip());
if (port < 1 || port > 65535) {
return new Err<>("port out of range: " + port);
}
return new Ok<>(port);
} catch (NumberFormatException e) {
return new Err<>("not a number: " + raw);
}
}
void main() {
for (String raw : List.of("8080", "70000", "http")) {
String line = switch (parsePort(raw)) {
case Ok<Integer> ok -> "listening on " + ok.value();
case Err<Integer> err -> "config error, " + err.message();
};
IO.println(line);
}
}
// Output:
// listening on 8080
// config error, port out of range: 70000
// config error, not a number: http
Discussion