Inheritance in Java has always been unrestricted by default. Any class can extend any non-final class. Any class can implement any interface. This openness is a feature when you genuinely want extensibility — a plugin system, a framework’s extension point, a library’s public API. It becomes a liability when you have a concept that should have exactly N known variants and the type system has no way to express that. A return type that is either a result or an error. A domain event that is one of five known event types. An AST node that is one of a dozen well-defined node kinds. Before Java 17, the only ways to bound a type hierarchy were to make the class final (no subtypes at all) or to use package-private constructors (subtypes limited to the same package, which constrains the caller as much as the implementor).
Sealed classes, finalized in Java 17 (JEP 409), address this directly. A sealed class or interface declares exactly which classes may extend or implement it. The compiler enforces this declaration. Any attempt to extend outside the permitted set fails at compile time. This makes sealed types the right tool whenever a concept has a fixed, bounded set of variants — and it unlocks a second capability: exhaustiveness checking in pattern matching for switch.
The Sealed Keyword: Bounding a Type Hierarchy
A sealed interface or class declares its permitted subtypes in the class declaration using the permits clause. Each permitted type must be in the same compilation unit (same package, if not in the same file) and must explicitly declare its relationship to the sealed parent using one of three modifiers: final (no further extension), sealed (further bounded extension), or non-sealed (reopens the hierarchy for arbitrary extension).
// Java 17+ — sealed interface with three permitted implementing types
// Each permitted type must declare its own relationship to the hierarchy
sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Declined, PaymentResult.Error {
// Success is final — no subtypes of Success
record Success(String transactionId, long amount) implements PaymentResult {}
// Declined is sealed — only the listed types may extend it
sealed interface Declined extends PaymentResult
permits Declined.InsufficientFunds, Declined.CardExpired {
record InsufficientFunds(long availableBalance) implements Declined {}
record CardExpired(String maskedPan) implements Declined {}
}
// Error is non-sealed — allows extension by any class, reopening the hierarchy
// Use this when third-party code legitimately needs to add variants
non-sealed interface Error extends PaymentResult {
String message();
Throwable cause();
}
}Code language: Java (java)
The non-sealed modifier is important to understand because it exists to prevent the sealed hierarchy from being too rigid. If you are writing a library and you want to allow integrators to add their own error variants while keeping the success/declined variants closed, non-sealed on the error type achieves exactly that. It reopens the hierarchy at exactly one point without affecting the rest.
Sealed Classes with Records: Algebraic Data Types in Java
Records are value-oriented data carriers — immutable, with accessor methods generated automatically, and with equals, hashCode, and toString derived from their components. When you combine records with sealed interfaces, each variant in the sealed hierarchy is a named, immutable data structure. The hierarchy defines what variants exist; the records define what data each variant carries. This combination is what other languages call algebraic data types or discriminated unions.
// Java 17+ — sealed + records as algebraic data types for a validation pipeline
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.function.Function;
sealed interface ValidationResult<T>
permits ValidationResult.Valid, ValidationResult.Invalid {
record Valid<T>(T value) implements ValidationResult<T> {}
record Invalid<T>(List<String> errors) implements ValidationResult<T> {
// Compact constructor — validates that errors is never empty
public Invalid {
if (errors == null || errors.isEmpty()) {
throw new IllegalArgumentException("Invalid must have at least one error");
}
}
}
// A utility method on the sealed interface itself
default <R> ValidationResult<R> map(Function<T, R> mapper) {
return switch (this) {
case Valid<T> v -> new Valid<>(mapper.apply(v.value()));
case Invalid<T> inv -> new Invalid<>(inv.errors());
};
}
}
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public ValidationResult<Order> validate(OrderRequest req) {
List<String> errors = new java.util.ArrayList<>();
if (req.amount() <= 0) errors.add("amount must be positive");
if (req.customerId() == null) errors.add("customerId is required");
if (errors.isEmpty()) {
return new ValidationResult.Valid<>(new Order(req.customerId(), req.amount()));
}
return new ValidationResult.Invalid<>(errors);
}
public void processRequest(OrderRequest req) {
ValidationResult<Order> result = validate(req);
switch (result) {
case ValidationResult.Valid<Order> v -> {
log.info("Processing order for customer {}", v.value().customerId());
fulfillOrder(v.value());
}
case ValidationResult.Invalid<Order> inv ->
inv.errors().forEach(e -> log.warn("Validation error: {}", e));
}
}
private void fulfillOrder(Order order) { /* implementation */ }
record Order(String customerId, long amount) {}
record OrderRequest(String customerId, long amount) {}
}Code language: Java (java)
Exhaustiveness Checking: The Real Payoff
The most significant operational benefit of sealed types appears when you switch over them. A switch expression or statement over a sealed type with no default case is checked by the compiler for exhaustiveness. If every permitted subtype has a corresponding case, the default is not needed. If you add a new permitted subtype later, every switch without a default causes a compile error at the exact location where handling is missing.
This is a fundamentally different failure mode from open hierarchies. With an open interface, adding a new implementing type is silent — existing switch statements fall to the default case or throw UnsupportedOperationException at runtime. With a sealed interface, the compiler surfaces every location that needs to handle the new type. The cost of adding a variant is paid at the addition site and the compile-time refusal to build, not at runtime months later in production.
// Java 21+ — exhaustiveness enforcement with sealed types in switch
// Adding a new permitted type to Command immediately surfaces all switches that need updating
sealed interface Command permits Command.Start, Command.Stop, Command.Pause {
record Start(String jobId) implements Command {}
record Stop(String jobId, boolean graceful) implements Command {}
record Pause(String jobId, long durationMs) implements Command {}
}
public class CommandProcessor {
// Compile error if any permitted Command type has no case and no default
public void execute(Command cmd) {
switch (cmd) {
case Command.Start s -> startJob(s.jobId());
case Command.Stop s -> stopJob(s.jobId(), s.graceful());
case Command.Pause p -> pauseJob(p.jobId(), p.durationMs());
// No default — if Command.Resume is added to permits, this switch fails to compile
}
}
// A switch expression also enforces exhaustiveness when it returns a value
public String describe(Command cmd) {
return switch (cmd) {
case Command.Start s -> "Starting job " + s.jobId();
case Command.Stop s -> "Stopping job " + s.jobId() + (s.graceful() ? " gracefully" : " immediately");
case Command.Pause p -> "Pausing job " + p.jobId() + " for " + p.durationMs() + "ms";
};
}
private void startJob(String id) { /* implementation */ }
private void stopJob(String id, boolean graceful) { /* implementation */ }
private void pauseJob(String id, long durationMs) { /* implementation */ }
}Code language: Java (java)
Where Sealed Classes Fit: Real Patterns
Three patterns recur often enough to be worth naming.
The first is the result type: a sealed interface with two variants, one carrying a successful value and one carrying an error. This replaces either checked exceptions (which are viral and affect caller signatures) or Optional (which loses the error information). The result type makes the error case explicit in the type system, forces callers to handle both paths, and carries whatever error information the failure case warrants. The ValidationResult<T> in the earlier example is this pattern.
The second is the command/event type: a sealed interface representing all the meaningful events or commands in a bounded context. Event sourcing systems, CQRS command handlers, and state machines all benefit from this. The sealed hierarchy makes every possible event visible in one place, and exhaustive pattern matching ensures every handler handles every event.
The third is the AST/expression type: sealed interfaces representing parse trees or expression structures. Compilers, interpreters, query builders, and rule engines built in Java have traditionally used the visitor pattern for this — an abstract class with a visitor interface and an accept method. Sealed types with pattern matching produce the same result with less machinery.
When Not to Use Sealed Classes
Sealed classes are for bounded, stable variant sets. They are not the right tool when the set of variants is expected to grow as the system evolves, when third-party code needs to add variants as a designed extension point, or when the variants differ in behaviour rather than data. If every variant implements the same interface method differently, that is polymorphism — the sealed type is not adding value over a plain abstract class.
Also avoid creating sealed hierarchies just to enforce a naming convention or grouping. Sealed types exist to enable exhaustiveness checking and bounded dispatch. If you are not switching over the type, the sealed modifier provides documentation value but no compile-time enforcement. In that case, a plain interface or abstract class may be more appropriate.
Interview Questions
What happens when a sealed interface has a non-sealed implementing class and a switch expression over the sealed interface omits a default case?
The switch does not compile. The compiler knows about all direct permitted subtypes of the sealed interface, but non-sealed means the class may have unknown subclasses. Those subclasses could appear at the switch site at runtime, and the compiler cannot enumerate them. Without a default, the switch is not exhaustive — values of unknown subtypes would have no matching case. The fix is either adding a default case that handles unknown subtypes (typically throwing), or keeping all permitted types sealed or final to allow the compiler to verify exhaustiveness.
How does the system behave when a new record is added to a sealed interface’s permits clause and existing switch statements have no default?
All switch expressions and statements over that sealed interface that lack a default case immediately fail to compile. This is the intended behaviour. The compiler’s exhaustiveness check requires that every permitted direct subtype has a matching case. The compile failure surfaces every location in the codebase that handles the sealed type — the developer sees exactly where new handling is needed before the code can build. This is the sealed class advantage over adding an implementing class to an open interface, where the gap would only be discovered at runtime if a default case was present but incorrect, or via UnsupportedOperationException if not.
What issues arise when using sealed interfaces as return types across module boundaries where the caller module only has access to the sealed interface, not the implementing records?
The caller module can receive values of the sealed interface type but cannot construct them, cannot name the implementing types in switch cases, and cannot perform exhaustive pattern matching. The compiler’s exhaustiveness check requires visibility of the permitted types. If the implementing records are in a package not exported to the caller module, the switch must include a default case, losing exhaustiveness guarantees. This is generally a sign of a module boundary design issue — result types used across module boundaries should be either exported with their permitted subtypes, or replaced by a common API type like a record with a status enum that does not require sealed exhaustiveness checking.
Summary
Sealed classes solve the specific problem of expressing that a type hierarchy has a fixed, compiler-enforced set of variants. Combined with records, they give Java algebraic data types: named, immutable, data-carrying variants under a common interface. Combined with pattern matching for switch, they give exhaustiveness checking: the compiler verifies that every variant is handled, and adding a variant surfaces all the locations that need updating at compile time. Use them for result types, event types, command types, and expression trees — anywhere the set of meaningful variants is known at design time and should not be extended without intentional review.




