Sealed classes became a permanent feature in Java 17 (JEP 409) after two preview rounds in Java 15 and 16. They give you controlled inheritance — you declare exactly which classes can extend a parent, and the compiler enforces it. If you are not yet familiar with the fundamentals of sealed classes — the sealed keyword, the permits clause, and basic syntax — read the Java Sealed Class fundamentals post first. This post assumes you know the basics and focuses on why sealed classes were added to Java 17, how they integrate with records and pattern matching, and how to use them effectively in real-world domain modeling.
To understand why sealed classes exist, you need to understand a problem that Java developers have battled for decades: uncontrolled inheritance.
When you write a class hierarchy in Java — say, a Shape class with Circle, Rectangle, and Triangle subclasses — you have no way to say “these three subclasses are the complete set.” Anyone can extend Shape with a Hexagon or BlobShape, and your carefully written code that processes shapes now has a type it does not know about. This causes real problems:
throw new IllegalArgumentException("Unknown shape") in every method that dispatches on type, cluttering your code with error handling for cases that should be structurally impossible.Languages like Kotlin (sealed class), Scala (sealed trait), Rust (enum with data), and Haskell (algebraic data types) solved this years ago. Java 17 finally brought this capability to the JVM.
Sealed classes solve this by introducing a permits clause that explicitly lists every permitted subclass. The compiler enforces three guarantees:
| Guarantee | What It Means | Why It Matters |
|---|---|---|
| Closed set of subtypes | Only classes listed in permits can extend the sealed class |
No surprise subclasses breaking your assumptions |
| Every subclass must declare its inheritance contract | Permitted subclasses must be final, sealed, or non-sealed |
You control how deep the hierarchy goes |
| Exhaustive pattern matching | The compiler knows all possible subtypes and can verify completeness | No more default branches hiding missing cases |
Every class that extends a sealed class must declare one of three modifiers: final, sealed, or non-sealed. This is not optional — the compiler requires it. Each modifier answers a different question about how the hierarchy continues below that subclass.
| Modifier | Can Be Extended? | When to Use | Analogy |
|---|---|---|---|
final |
No — the hierarchy stops here | Leaf nodes. The subclass is a concrete, complete type with no further specialization needed. | A dead end in a hallway — no more doors |
sealed |
Yes, but only by its own permitted subclasses | Intermediate nodes. The subclass has its own closed set of subtypes. | A locked door that opens to another hallway with its own guest list |
non-sealed |
Yes, by anyone | Extension points. You want to allow open-ended inheritance from this branch. | An open door — anyone can walk through |
// A complete example showing all three modifiers
public sealed class Vehicle permits Car, Truck, Motorcycle {
private final String vin;
public Vehicle(String vin) { this.vin = vin; }
public String getVin() { return vin; }
}
// FINAL: no further subclassing
// A Motorcycle is a Motorcycle. No SportMotorcycle, no Scooter extends this.
final class Motorcycle extends Vehicle {
private final int engineCC;
public Motorcycle(String vin, int engineCC) {
super(vin);
this.engineCC = engineCC;
}
}
// SEALED: controlled further subclassing
// A Car can be Sedan or SUV, but nothing else.
sealed class Car extends Vehicle permits Sedan, SUV {
private final int doors;
public Car(String vin, int doors) {
super(vin);
this.doors = doors;
}
}
final class Sedan extends Car {
public Sedan(String vin) { super(vin, 4); }
}
final class SUV extends Car {
private final boolean fourWheelDrive;
public SUV(String vin, boolean fourWheelDrive) {
super(vin, 4);
this.fourWheelDrive = fourWheelDrive;
}
}
// NON-SEALED: open for extension
// Anyone can create new Truck types -- it's an extension point
non-sealed class Truck extends Vehicle {
private final double payloadTons;
public Truck(String vin, double payloadTons) {
super(vin);
this.payloadTons = payloadTons;
}
}
// These are allowed because Truck is non-sealed:
class SemiTruck extends Truck {
public SemiTruck(String vin) { super(vin, 40.0); }
}
class PickupTruck extends Truck {
public PickupTruck(String vin) { super(vin, 1.5); }
}
When designing a sealed hierarchy, ask these questions for each permitted subclass:
final.sealed with its own permits clause.non-sealed. This is the escape hatch when part of your hierarchy needs to remain open.Rule of thumb: Start with final everywhere. Only loosen to sealed or non-sealed when you have a clear reason. This follows the principle of least privilege — grant the minimum access needed.
Records and sealed classes are a match made in heaven. Records are implicitly final, which means they automatically satisfy the requirement for permitted subclasses. Together, they let you build algebraic data types in Java — a concept borrowed from functional programming languages.
An algebraic data type (ADT) is a type that is defined by the set of values it can hold. Think of it as a “this OR that” type. A Result is either a Success or a Failure. A Shape is either a Circle, a Rectangle, or a Triangle. Each variant can carry different data.
// Algebraic data type: Shape is Circle OR Rectangle OR Triangle
public sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
}
// Each record is a data carrier -- immutable, with auto-generated
// equals(), hashCode(), toString(), and accessor methods
record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
record Rectangle(double width, double height) implements Shape {
public double area() { return width * height; }
}
record Triangle(double base, double height) implements Shape {
public double area() { return 0.5 * base * height; }
}
// Usage
Shape shape = new Circle(5.0);
System.out.println(shape.area()); // 78.53981633974483
System.out.println(shape); // Circle[radius=5.0] (auto toString)
One of the most powerful patterns with sealed classes + records is the Result type — a way to represent operations that can either succeed or fail, without using exceptions for control flow:
// A generic Result type -- Success OR Failure public sealed interface Resultpermits Result.Success, Result.Failure { record Success (T value) implements Result { } record Failure (String error, Exception cause) implements Result { // Convenience constructor without cause Failure(String error) { this(error, null); } } // Factory methods static Result success(T value) { return new Success<>(value); } static Result failure(String error) { return new Failure<>(error); } static Result failure(String error, Exception cause) { return new Failure<>(error, cause); } } // Using the Result type public class UserService { public Result findUser(String email) { if (email == null || email.isBlank()) { return Result.failure("Email cannot be blank"); } try { User user = database.findByEmail(email); if (user == null) { return Result.failure("User not found: " + email); } return Result.success(user); } catch (Exception e) { return Result.failure("Database error", e); } } } // Processing the result with pattern matching Result result = userService.findUser("alice@example.com"); if (result instanceof Result.Success success) { System.out.println("Found: " + success.value().getName()); } else if (result instanceof Result.Failure failure) { System.out.println("Error: " + failure.error()); }
| Feature | Records Provide | Sealed Classes Provide | Together |
|---|---|---|---|
| Immutability | Fields are final, no setters | Hierarchy is fixed | Both data and structure are immutable |
| Boilerplate reduction | Auto equals/hashCode/toString | No need for visitor pattern | Minimal code for maximum expressiveness |
| Pattern matching | Record patterns (Java 21) | Exhaustive type matching | Full destructuring of sealed hierarchies |
| Finality | Records are implicitly final | Requires final/sealed/non-sealed | Records automatically satisfy the requirement |
Sealed interfaces follow the same rules as sealed classes, but they are often the better choice because Java supports multiple interface implementation but only single class inheritance. This gives your design more flexibility.
| Criteria | Sealed Interface | Sealed Class |
|---|---|---|
| Shared state (fields) | No — interfaces cannot have instance fields | Yes — subclasses inherit fields and constructors |
| Multiple inheritance | A class can implement multiple sealed interfaces | A class can extend only one sealed class |
| Default methods | Yes — you can provide default implementations | Yes — inherited methods |
| Permitted types | Classes, records, enums, or other interfaces | Classes only (not records, not enums) |
| Best for | Defining behaviors or capabilities | Defining shared structure with common state |
Rule of thumb: Prefer sealed interfaces when your subtypes are primarily distinguished by what data they carry (use records). Prefer sealed classes when your subtypes need to share common state and behavior (use inherited fields and methods).
// Sealed interface -- permits records, classes, or other interfaces
public sealed interface Notification
permits EmailNotification, SmsNotification, PushNotification, UrgentNotification {
String message();
String recipient();
}
// Records implementing the sealed interface
record EmailNotification(String recipient, String message, String subject)
implements Notification { }
record SmsNotification(String recipient, String message)
implements Notification { }
record PushNotification(String recipient, String message, String deviceToken)
implements Notification { }
// A sealed interface can permit another sealed interface
sealed interface UrgentNotification extends Notification
permits EmergencyAlert, SystemAlert {
}
record EmergencyAlert(String recipient, String message, int severityLevel)
implements UrgentNotification { }
record SystemAlert(String recipient, String message, String systemName)
implements UrgentNotification { }
// Processing all notification types
public void send(Notification notification) {
if (notification instanceof EmailNotification email) {
sendEmail(email.recipient(), email.subject(), email.message());
} else if (notification instanceof SmsNotification sms) {
sendSms(sms.recipient(), sms.message());
} else if (notification instanceof PushNotification push) {
sendPush(push.deviceToken(), push.message());
} else if (notification instanceof EmergencyAlert alert) {
triggerEmergencyProtocol(alert.severityLevel(), alert.message());
} else if (notification instanceof SystemAlert sysAlert) {
logSystemAlert(sysAlert.systemName(), sysAlert.message());
}
}
Enums can also implement sealed interfaces, which is useful when some variants are simple constants (enums) and others carry data (records):
// Mixing enums and records under a sealed interface
public sealed interface HttpStatus
permits HttpStatus.Info, HttpStatus.Success, HttpStatus.Redirect,
HttpStatus.ClientError, HttpStatus.ServerError {
int code();
String reason();
// Simple statuses as enum constants
enum Info implements HttpStatus {
CONTINUE(100, "Continue"),
SWITCHING_PROTOCOLS(101, "Switching Protocols");
private final int code;
private final String reason;
Info(int code, String reason) { this.code = code; this.reason = reason; }
public int code() { return code; }
public String reason() { return reason; }
}
enum Success implements HttpStatus {
OK(200, "OK"),
CREATED(201, "Created"),
NO_CONTENT(204, "No Content");
private final int code;
private final String reason;
Success(int code, String reason) { this.code = code; this.reason = reason; }
public int code() { return code; }
public String reason() { return reason; }
}
// Error statuses with extra detail as records
record Redirect(int code, String reason, String location) implements HttpStatus { }
record ClientError(int code, String reason, String detail) implements HttpStatus { }
record ServerError(int code, String reason, String stackTrace) implements HttpStatus { }
}
Sealed classes and pattern matching are designed to work together. When the compiler knows the complete set of subtypes (because the hierarchy is sealed), it can verify that your code handles every possible case. This is called exhaustive pattern matching, and it eliminates an entire category of bugs — the “I forgot to handle this case” bug.
Note: Pattern matching in switch was a preview feature in Java 17 (JEP 406) and became finalized in Java 21 (JEP 441). The examples below require Java 21 for production use, but they illustrate the design intent behind sealed classes in Java 17.
// Sealed hierarchy
public sealed interface Shape permits Circle, Rectangle, Triangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }
// Exhaustive switch -- NO default needed!
// The compiler knows Shape can only be Circle, Rectangle, or Triangle.
public double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed -- the compiler verifies all cases are covered
};
}
// What happens when you add a new permitted subclass?
// If you add: record Pentagon(double side) implements Shape { }
// Every switch that doesn't handle Pentagon will FAIL TO COMPILE.
// The compiler forces you to handle the new type everywhere.
With unsealed types, you always need a default branch because someone could add a new subclass at any time. That default branch is a hiding place for bugs — when a new type is added, the code silently falls into the default case instead of failing loudly. Sealed classes eliminate the need for default, turning silent failures into compile-time errors.
// Pattern matching with guards (when clauses) -- Java 21
public String describe(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "Large circle";
case Circle c when c.radius() > 10 -> "Medium circle";
case Circle c -> "Small circle";
case Rectangle r when r.width() == r.height() -> "Square: " + r.width();
case Rectangle r -> "Rectangle: " + r.width() + "x" + r.height();
case Triangle t when t.base() == t.height() -> "Isoceles-looking triangle";
case Triangle t -> "Triangle: base=" + t.base();
};
}
// Pattern matching with instanceof in Java 17 (works today)
public String describeJava17(Shape shape) {
if (shape instanceof Circle c && c.radius() > 100) {
return "Large circle";
} else if (shape instanceof Circle c && c.radius() > 10) {
return "Medium circle";
} else if (shape instanceof Circle c) {
return "Small circle";
} else if (shape instanceof Rectangle r && r.width() == r.height()) {
return "Square: " + r.width();
} else if (shape instanceof Rectangle r) {
return "Rectangle: " + r.width() + "x" + r.height();
} else if (shape instanceof Triangle t) {
return "Triangle: base=" + t.base();
}
throw new IllegalArgumentException("Unknown shape"); // still needed in Java 17
}
| Java Version | Feature | Status |
|---|---|---|
| Java 15 | Sealed classes (preview) | JEP 360 |
| Java 16 | Sealed classes (second preview), Pattern matching for instanceof (final) | JEP 397, JEP 394 |
| Java 17 | Sealed classes (final), Pattern matching in switch (preview) | JEP 409, JEP 406 |
| Java 21 | Pattern matching in switch (final), Record patterns (final) | JEP 441, JEP 440 |
Java 17 gives you the foundation — sealed classes and instanceof pattern matching. Java 21 completes the picture with switch pattern matching and record deconstruction. Designing your sealed hierarchies well in Java 17 means you are ready to take full advantage of these features when you upgrade.
Sealed classes are most powerful when used for domain modeling — representing the real-world concepts your application works with. In many domains, the set of possible types is inherently fixed and known at design time.
// A payment is exactly one of these four types
public sealed interface Payment permits
CreditCardPayment, DebitCardPayment, BankTransfer, DigitalWallet {
double amount();
String currency();
}
record CreditCardPayment(double amount, String currency,
String cardNumber, String expiryDate, int cvv)
implements Payment { }
record DebitCardPayment(double amount, String currency,
String cardNumber, String pin)
implements Payment { }
record BankTransfer(double amount, String currency,
String iban, String swift, String reference)
implements Payment { }
record DigitalWallet(double amount, String currency,
String walletId, String provider) // "PayPal", "ApplePay", etc.
implements Payment { }
// Processing payments -- the compiler guarantees you handle all types
public class PaymentProcessor {
public PaymentReceipt process(Payment payment) {
if (payment instanceof CreditCardPayment cc) {
validateCreditCard(cc.cardNumber(), cc.expiryDate(), cc.cvv());
return chargeCard(cc);
} else if (payment instanceof DebitCardPayment dc) {
validatePin(dc.pin());
return chargeDebit(dc);
} else if (payment instanceof BankTransfer bt) {
validateIban(bt.iban());
return initiateBankTransfer(bt);
} else if (payment instanceof DigitalWallet dw) {
return processWalletPayment(dw);
}
throw new AssertionError("Unreachable -- all Payment types handled");
}
// Fee calculation -- different for each payment type
public double calculateFee(Payment payment) {
if (payment instanceof CreditCardPayment cc) {
return cc.amount() * 0.029 + 0.30; // 2.9% + $0.30
} else if (payment instanceof DebitCardPayment dc) {
return dc.amount() * 0.015; // 1.5% flat
} else if (payment instanceof BankTransfer bt) {
return 5.00; // flat fee
} else if (payment instanceof DigitalWallet dw) {
return dw.amount() * 0.025; // 2.5%
}
throw new AssertionError("Unreachable");
}
}
Compilers and interpreters frequently model expressions as a tree. Each node type has different data, and the set of node types is fixed by the language grammar.
// An expression in a simple calculator language
public sealed interface Expr permits
Expr.Literal, Expr.Variable, Expr.BinaryOp, Expr.UnaryOp, Expr.FunctionCall {
record Literal(double value) implements Expr { }
record Variable(String name) implements Expr { }
record BinaryOp(Expr left, String operator, Expr right) implements Expr { }
record UnaryOp(String operator, Expr operand) implements Expr { }
record FunctionCall(String name, List args) implements Expr { }
}
// Evaluator using pattern matching
public class ExprEvaluator {
private final Map variables;
public ExprEvaluator(Map variables) {
this.variables = variables;
}
public double evaluate(Expr expr) {
if (expr instanceof Expr.Literal lit) {
return lit.value();
} else if (expr instanceof Expr.Variable v) {
Double val = variables.get(v.name());
if (val == null) throw new RuntimeException("Undefined: " + v.name());
return val;
} else if (expr instanceof Expr.BinaryOp bin) {
double left = evaluate(bin.left());
double right = evaluate(bin.right());
return switch (bin.operator()) {
case "+" -> left + right;
case "-" -> left - right;
case "*" -> left * right;
case "/" -> {
if (right == 0) throw new ArithmeticException("Division by zero");
yield left / right;
}
default -> throw new RuntimeException("Unknown op: " + bin.operator());
};
} else if (expr instanceof Expr.UnaryOp unary) {
double operand = evaluate(unary.operand());
return switch (unary.operator()) {
case "-" -> -operand;
case "+" -> operand;
default -> throw new RuntimeException("Unknown op: " + unary.operator());
};
} else if (expr instanceof Expr.FunctionCall fn) {
return switch (fn.name()) {
case "sqrt" -> Math.sqrt(evaluate(fn.args().get(0)));
case "abs" -> Math.abs(evaluate(fn.args().get(0)));
case "max" -> Math.max(
evaluate(fn.args().get(0)),
evaluate(fn.args().get(1))
);
default -> throw new RuntimeException("Unknown function: " + fn.name());
};
}
throw new AssertionError("Unknown expression type");
}
}
// Building and evaluating: (x + 2) * 3
Expr expr = new Expr.BinaryOp(
new Expr.BinaryOp(
new Expr.Variable("x"),
"+",
new Expr.Literal(2)
),
"*",
new Expr.Literal(3)
);
ExprEvaluator eval = new ExprEvaluator(Map.of("x", 5.0));
System.out.println(eval.evaluate(expr)); // 21.0
// Validation that reports either success or detailed errors
public sealed interface ValidationResult
permits ValidationResult.Valid, ValidationResult.Invalid {
record Valid() implements ValidationResult { }
record Invalid(List errors) implements ValidationResult {
Invalid(String singleError) {
this(List.of(singleError));
}
}
// Factory methods
static ValidationResult valid() { return new Valid(); }
static ValidationResult invalid(String error) {
return new Invalid(error);
}
static ValidationResult invalid(List errors) {
return new Invalid(errors);
}
// Combine multiple validation results
static ValidationResult combine(ValidationResult... results) {
List allErrors = new ArrayList<>();
for (ValidationResult r : results) {
if (r instanceof Invalid inv) {
allErrors.addAll(inv.errors());
}
}
return allErrors.isEmpty() ? valid() : invalid(allErrors);
}
}
// Using the validation result
public class UserValidator {
public ValidationResult validate(String name, String email, int age) {
return ValidationResult.combine(
validateName(name),
validateEmail(email),
validateAge(age)
);
}
private ValidationResult validateName(String name) {
if (name == null || name.isBlank()) {
return ValidationResult.invalid("Name is required");
}
if (name.length() < 2) {
return ValidationResult.invalid("Name must be at least 2 characters");
}
return ValidationResult.valid();
}
private ValidationResult validateEmail(String email) {
if (email == null || !email.contains("@")) {
return ValidationResult.invalid("Valid email is required");
}
return ValidationResult.valid();
}
private ValidationResult validateAge(int age) {
if (age < 0 || age > 150) {
return ValidationResult.invalid("Age must be between 0 and 150");
}
return ValidationResult.valid();
}
}
// Processing the result
UserValidator validator = new UserValidator();
ValidationResult result = validator.validate("", "invalid", -5);
if (result instanceof ValidationResult.Valid) {
System.out.println("All good -- proceed");
} else if (result instanceof ValidationResult.Invalid inv) {
System.out.println("Validation failed:");
inv.errors().forEach(e -> System.out.println(" - " + e));
}
// Output:
// Validation failed:
// - Name is required
// - Valid email is required
// - Age must be between 0 and 150
Enums and sealed classes both represent a fixed set of types, but they serve different purposes. Understanding when to use each is important for clean design.
| Criteria | Enum | Sealed Class/Interface |
|---|---|---|
| Instances | Fixed set of singleton instances (RED, GREEN, BLUE) |
Unlimited instances, each can carry different data |
| Data per instance | Same fields for all constants, set in constructor | Different fields per subclass/record |
| Behavior per instance | Can override methods per constant, but it gets awkward | Each subclass has its own methods and behavior naturally |
| Inheritance | Enums cannot extend classes (they implicitly extend java.lang.Enum) |
Full class hierarchy with inheritance |
| Serialization | Built-in, name-based | You implement it yourself |
| Switch exhaustiveness | Supported since Java 14 (switch expressions) | Supported in Java 21 (pattern matching switch) |
values() method |
Yes — iterate over all instances | No built-in equivalent |
| Best for | Simple constants, status codes, flags | Complex types with variant-specific data and behavior |
Use an enum when every instance has the same structure (same fields, same interface) and the values are known singletons. Think: days of the week, card suits, log levels.
Use a sealed class/interface when different instances need different data. Think: payment methods (credit card needs card number, bank transfer needs IBAN), AST nodes (literal has a value, binary operation has left/right/operator).
// ENUM: All instances have the same structure
public enum LogLevel {
DEBUG(0, "DEBUG"),
INFO(1, "INFO"),
WARN(2, "WARN"),
ERROR(3, "ERROR"),
FATAL(4, "FATAL");
private final int severity;
private final String label;
LogLevel(int severity, String label) {
this.severity = severity;
this.label = label;
}
public boolean isAtLeast(LogLevel other) {
return this.severity >= other.severity;
}
}
// SEALED CLASS: Each variant carries DIFFERENT data
public sealed interface LogEntry permits
LogEntry.TextLog, LogEntry.ExceptionLog, LogEntry.MetricLog {
LocalDateTime timestamp();
LogLevel level();
record TextLog(LocalDateTime timestamp, LogLevel level, String message)
implements LogEntry { }
record ExceptionLog(LocalDateTime timestamp, LogLevel level,
String message, Throwable exception, String stackTrace)
implements LogEntry { }
record MetricLog(LocalDateTime timestamp, LogLevel level,
String metricName, double value, Map tags)
implements LogEntry { }
}
// The enum tells you HOW SEVERE the log is.
// The sealed interface tells you WHAT KIND of log it is.
// They work together -- they are not competing concepts.
The visitor pattern has been the traditional Java answer to the question: “How do I add new operations to a class hierarchy without modifying the classes?” It works, but it is notoriously verbose and hard to understand. Sealed classes offer a cleaner alternative for many use cases.
// Step 1: Define a visitor interface with a method per type interface DocumentVisitor{ T visit(Paragraph p); T visit(Heading h); T visit(Image img); T visit(CodeBlock cb); } // Step 2: Every document element needs an accept() method interface DocumentElement { T accept(DocumentVisitor visitor); } class Paragraph implements DocumentElement { private final String text; Paragraph(String text) { this.text = text; } String getText() { return text; } public T accept(DocumentVisitor v) { return v.visit(this); } } class Heading implements DocumentElement { private final String text; private final int level; Heading(String text, int level) { this.text = text; this.level = level; } String getText() { return text; } int getLevel() { return level; } public T accept(DocumentVisitor v) { return v.visit(this); } } class Image implements DocumentElement { private final String url; private final String alt; Image(String url, String alt) { this.url = url; this.alt = alt; } String getUrl() { return url; } String getAlt() { return alt; } public T accept(DocumentVisitor v) { return v.visit(this); } } class CodeBlock implements DocumentElement { private final String code; private final String language; CodeBlock(String code, String language) { this.code = code; this.language = language; } String getCode() { return code; } String getLanguage() { return language; } public T accept(DocumentVisitor v) { return v.visit(this); } } // Step 3: Implement the visitor for each operation class HtmlRenderer implements DocumentVisitor { public String visit(Paragraph p) { return " " + p.getText() + "
"; } public String visit(Heading h) { return "" + h.getText() + " "; } public String visit(Image img) { return ""; } public String visit(CodeBlock cb) { return "
" + cb.getCode() + ""; }
}// Usage: element.accept(new HtmlRenderer())
// That is a LOT of boilerplate for what is conceptually a simple type dispatch.The Sealed Classes Approach
// Same functionality, fraction of the code public sealed interface DocumentElement permits Paragraph, Heading, Image, CodeBlock { } record Paragraph(String text) implements DocumentElement { } record Heading(String text, int level) implements DocumentElement { } record Image(String url, String alt) implements DocumentElement { } record CodeBlock(String code, String language) implements DocumentElement { } // Operations are just methods that pattern-match on the sealed type public class DocumentRenderer { public static String toHtml(DocumentElement element) { if (element instanceof Paragraph p) { return "" + p.text() + "
"; } else if (element instanceof Heading h) { return "" + h.text() + " "; } else if (element instanceof Image img) { return ""; } else if (element instanceof CodeBlock cb) { return "
" + cb.code() + "";
}
throw new AssertionError("Unknown element type");
}public static String toMarkdown(DocumentElement element) {
if (element instanceof Paragraph p) {
return p.text() + "\n";
} else if (element instanceof Heading h) {
return "#".repeat(h.level()) + " " + h.text() + "\n";
} else if (element instanceof Image img) {
return " + ")\n";
} else if (element instanceof CodeBlock cb) {
return "```" + cb.language() + "\n" + cb.code() + "\n```\n";
}
throw new AssertionError("Unknown element type");
}public static int wordCount(DocumentElement element) {
if (element instanceof Paragraph p) {
return p.text().split("\\s+").length;
} else if (element instanceof Heading h) {
return h.text().split("\\s+").length;
} else if (element instanceof Image img) {
return 0;
} else if (element instanceof CodeBlock cb) {
return 0;
}
throw new AssertionError("Unknown element type");
}
}// Usage: String html = DocumentRenderer.toHtml(element);
// No visitor interface, no accept() methods, no double dispatch.Visitor vs Sealed Classes: Trade-offs
Aspect Visitor Pattern Sealed Classes + Pattern Matching Adding a new operation Easy — implement a new visitor class Easy — write a new method Adding a new type Hard — must update the visitor interface and ALL implementations Hard — must update all pattern matching methods. But the compiler flags incomplete sealed switches. Boilerplate High — visitor interface, accept() on every class Low — just the sealed interface and records Type safety on new types Compile-time (adding to visitor interface forces updates) Compile-time with sealed switch (Java 21), runtime otherwise Readability Low — logic is spread across visitor methods High — all logic for one operation is in one place Third-party types Works — visitor can be external Works — pattern matching is external Bottom line: For most use cases, sealed classes + pattern matching is simpler, more readable, and less boilerplate than the visitor pattern. The visitor pattern still has its place for very complex cases where you need double dispatch or where the operation set grows independently from the type set, but those cases are rarer than many textbooks suggest.
10. Practical Examples
Let us walk through three complete, production-quality examples that demonstrate sealed classes in real-world scenarios.
Example 1: Expression Evaluator
A simple mathematical expression evaluator that supports basic arithmetic, variables, and function calls. This is a common interview question and a classic use case for sealed hierarchies.
import java.util.*; // The expression hierarchy -- complete and closed public sealed interface Expression permits Expression.Number, Expression.Var, Expression.Binary, Expression.Unary, Expression.Conditional { record Number(double value) implements Expression { } record Var(String name) implements Expression { } record Binary(Expression left, Op operator, Expression right) implements Expression { enum Op { ADD, SUB, MUL, DIV, MOD, POW } } record Unary(UnaryOp operator, Expression operand) implements Expression { enum UnaryOp { NEGATE, ABS, SQRT } } record Conditional(Expression condition, Expression then, Expression otherwise) implements Expression { } } // Evaluator public class Calculator { private final Mapvariables; public Calculator(Map variables) { this.variables = Map.copyOf(variables); } public double evaluate(Expression expr) { if (expr instanceof Expression.Number num) { return num.value(); } else if (expr instanceof Expression.Var v) { Double val = variables.get(v.name()); if (val == null) throw new RuntimeException("Undefined variable: " + v.name()); return val; } else if (expr instanceof Expression.Binary bin) { double left = evaluate(bin.left()); double right = evaluate(bin.right()); return switch (bin.operator()) { case ADD -> left + right; case SUB -> left - right; case MUL -> left * right; case DIV -> { if (right == 0) throw new ArithmeticException("Division by zero"); yield left / right; } case MOD -> left % right; case POW -> Math.pow(left, right); }; } else if (expr instanceof Expression.Unary unary) { double operand = evaluate(unary.operand()); return switch (unary.operator()) { case NEGATE -> -operand; case ABS -> Math.abs(operand); case SQRT -> { if (operand < 0) throw new ArithmeticException("Square root of negative"); yield Math.sqrt(operand); } }; } else if (expr instanceof Expression.Conditional cond) { return evaluate(cond.condition()) != 0 ? evaluate(cond.then()) : evaluate(cond.otherwise()); } throw new AssertionError("Unknown expression type"); } // Pretty-print the expression tree public String format(Expression expr) { if (expr instanceof Expression.Number num) { return String.valueOf(num.value()); } else if (expr instanceof Expression.Var v) { return v.name(); } else if (expr instanceof Expression.Binary bin) { String op = switch (bin.operator()) { case ADD -> "+"; case SUB -> "-"; case MUL -> "*"; case DIV -> "/"; case MOD -> "%"; case POW -> "^"; }; return "(" + format(bin.left()) + " " + op + " " + format(bin.right()) + ")"; } else if (expr instanceof Expression.Unary u) { return switch (u.operator()) { case NEGATE -> "-" + format(u.operand()); case ABS -> "abs(" + format(u.operand()) + ")"; case SQRT -> "sqrt(" + format(u.operand()) + ")"; }; } else if (expr instanceof Expression.Conditional c) { return "if(" + format(c.condition()) + ", " + format(c.then()) + ", " + format(c.otherwise()) + ")"; } throw new AssertionError("Unknown expression type"); } } // Usage public class Main { public static void main(String[] args) { // Build: sqrt(x^2 + y^2) -- the distance from origin Expression expr = new Expression.Unary( Expression.Unary.UnaryOp.SQRT, new Expression.Binary( new Expression.Binary( new Expression.Var("x"), Expression.Binary.Op.POW, new Expression.Number(2) ), Expression.Binary.Op.ADD, new Expression.Binary( new Expression.Var("y"), Expression.Binary.Op.POW, new Expression.Number(2) ) ) ); Calculator calc = new Calculator(Map.of("x", 3.0, "y", 4.0)); System.out.println(calc.format(expr)); // sqrt(((x ^ 2.0) + (y ^ 2.0))) System.out.println(calc.evaluate(expr)); // 5.0 } } Example 2: Payment Processor
import java.time.LocalDateTime; import java.util.*; // Payment hierarchy public sealed interface Payment permits Payment.CreditCard, Payment.BankTransfer, Payment.Crypto { double amount(); String currency(); record CreditCard(double amount, String currency, String cardNumber, String expiry) implements Payment { // Compact constructor for validation CreditCard { if (amount <= 0) throw new IllegalArgumentException("Amount must be positive"); if (cardNumber.length() != 16) throw new IllegalArgumentException("Card number must be 16 digits"); } } record BankTransfer(double amount, String currency, String fromIban, String toIban, String reference) implements Payment { BankTransfer { if (amount <= 0) throw new IllegalArgumentException("Amount must be positive"); } } record Crypto(double amount, String currency, String walletAddress, String network) implements Payment { Crypto { if (amount <= 0) throw new IllegalArgumentException("Amount must be positive"); if (!network.equals("ETH") && !network.equals("BTC") && !network.equals("SOL")) { throw new IllegalArgumentException("Unsupported network: " + network); } } } } // Transaction result public sealed interface TransactionResult permits TransactionResult.Approved, TransactionResult.Declined, TransactionResult.Pending { record Approved(String transactionId, LocalDateTime timestamp) implements TransactionResult { } record Declined(String reason, String errorCode) implements TransactionResult { } record Pending(String transactionId, String statusUrl) implements TransactionResult { } } // Processor public class PaymentProcessor { public TransactionResult process(Payment payment) { // Validate String validationError = validate(payment); if (validationError != null) { return new TransactionResult.Declined(validationError, "VALIDATION_FAILED"); } // Process based on type if (payment instanceof Payment.CreditCard cc) { return processCreditCard(cc); } else if (payment instanceof Payment.BankTransfer bt) { return processBankTransfer(bt); } else if (payment instanceof Payment.Crypto crypto) { return processCrypto(crypto); } throw new AssertionError("Unknown payment type"); } private String validate(Payment payment) { if (payment.amount() > 10_000 && payment instanceof Payment.CreditCard) { return "Credit card transactions limited to $10,000"; } if (payment instanceof Payment.Crypto c && c.amount() > 50_000) { return "Crypto transactions limited to $50,000"; } return null; // valid } private TransactionResult processCreditCard(Payment.CreditCard cc) { // Simulate card processing String txId = "CC-" + UUID.randomUUID().toString().substring(0, 8); System.out.printf("Charging %s %.2f to card ending in %s%n", cc.currency(), cc.amount(), cc.cardNumber().substring(12)); return new TransactionResult.Approved(txId, LocalDateTime.now()); } private TransactionResult processBankTransfer(Payment.BankTransfer bt) { // Bank transfers are async -- always pending initially String txId = "BT-" + UUID.randomUUID().toString().substring(0, 8); System.out.printf("Initiating %s %.2f transfer from %s to %s%n", bt.currency(), bt.amount(), bt.fromIban(), bt.toIban()); return new TransactionResult.Pending(txId, "/api/transfers/" + txId); } private TransactionResult processCrypto(Payment.Crypto crypto) { String txId = "CR-" + UUID.randomUUID().toString().substring(0, 8); System.out.printf("Sending %s %.4f to %s on %s network%n", crypto.currency(), crypto.amount(), crypto.walletAddress(), crypto.network()); return new TransactionResult.Approved(txId, LocalDateTime.now()); } // Generate receipt based on transaction result public String generateReceipt(Payment payment, TransactionResult result) { StringBuilder sb = new StringBuilder(); sb.append("=== Payment Receipt ===\n"); sb.append("Amount: ").append(payment.currency()).append(" ") .append(String.format("%.2f", payment.amount())).append("\n"); if (payment instanceof Payment.CreditCard cc) { sb.append("Method: Credit Card ending in ").append(cc.cardNumber().substring(12)).append("\n"); } else if (payment instanceof Payment.BankTransfer bt) { sb.append("Method: Bank Transfer (").append(bt.reference()).append(")\n"); } else if (payment instanceof Payment.Crypto c) { sb.append("Method: Crypto (").append(c.network()).append(")\n"); } if (result instanceof TransactionResult.Approved a) { sb.append("Status: APPROVED\n"); sb.append("Transaction ID: ").append(a.transactionId()).append("\n"); sb.append("Time: ").append(a.timestamp()).append("\n"); } else if (result instanceof TransactionResult.Declined d) { sb.append("Status: DECLINED\n"); sb.append("Reason: ").append(d.reason()).append("\n"); } else if (result instanceof TransactionResult.Pending p) { sb.append("Status: PENDING\n"); sb.append("Track at: ").append(p.statusUrl()).append("\n"); } return sb.toString(); } } // Usage public class Main { public static void main(String[] args) { PaymentProcessor processor = new PaymentProcessor(); Payment payment = new Payment.CreditCard( 99.99, "USD", "4111111111111111", "12/25" ); TransactionResult result = processor.process(payment); System.out.println(processor.generateReceipt(payment, result)); } }Example 3: Validation Pipeline
import java.util.*; import java.util.stream.*; // A validation rule that returns a result public sealed interface ValidationOutcome permits ValidationOutcome.Pass, ValidationOutcome.Fail, ValidationOutcome.Skip { record Pass(String ruleName) implements ValidationOutcome { } record Fail(String ruleName, String message, Severity severity) implements ValidationOutcome { enum Severity { WARNING, ERROR, CRITICAL } } record Skip(String ruleName, String reason) implements ValidationOutcome { } } // Validation pipeline public class ValidationPipeline{ private final List > rules = new ArrayList<>(); @FunctionalInterface interface ValidationRule { ValidationOutcome validate(T item); } public ValidationPipeline addRule(ValidationRule rule) { rules.add(rule); return this; } public ValidationReport validate(T item) { List outcomes = rules.stream() .map(rule -> rule.validate(item)) .collect(Collectors.toList()); return new ValidationReport(outcomes); } } // Report that summarizes all outcomes public class ValidationReport { private final List outcomes; public ValidationReport(List outcomes) { this.outcomes = List.copyOf(outcomes); } public boolean isValid() { return outcomes.stream().noneMatch(o -> o instanceof ValidationOutcome.Fail f && f.severity() == ValidationOutcome.Fail.Severity.ERROR ) && outcomes.stream().noneMatch(o -> o instanceof ValidationOutcome.Fail f && f.severity() == ValidationOutcome.Fail.Severity.CRITICAL ); } public List getErrors() { return outcomes.stream() .filter(o -> o instanceof ValidationOutcome.Fail) .map(o -> { if (o instanceof ValidationOutcome.Fail f) { return "[" + f.severity() + "] " + f.ruleName() + ": " + f.message(); } return ""; }) .filter(s -> !s.isEmpty()) .collect(Collectors.toList()); } public void printReport() { System.out.println("=== Validation Report ==="); for (ValidationOutcome outcome : outcomes) { if (outcome instanceof ValidationOutcome.Pass p) { System.out.println(" PASS: " + p.ruleName()); } else if (outcome instanceof ValidationOutcome.Fail f) { System.out.println(" FAIL [" + f.severity() + "]: " + f.ruleName() + " - " + f.message()); } else if (outcome instanceof ValidationOutcome.Skip s) { System.out.println(" SKIP: " + s.ruleName() + " (" + s.reason() + ")"); } } System.out.println("Result: " + (isValid() ? "VALID" : "INVALID")); } } // Usage: validating a user registration record UserRegistration(String name, String email, String password, int age) { } ValidationPipeline pipeline = new ValidationPipeline () .addRule(user -> { if (user.name() == null || user.name().isBlank()) return new ValidationOutcome.Fail("name-required", "Name is required", ValidationOutcome.Fail.Severity.ERROR); return new ValidationOutcome.Pass("name-required"); }) .addRule(user -> { if (user.email() == null || !user.email().contains("@")) return new ValidationOutcome.Fail("email-valid", "Valid email required", ValidationOutcome.Fail.Severity.ERROR); return new ValidationOutcome.Pass("email-valid"); }) .addRule(user -> { if (user.password().length() < 8) return new ValidationOutcome.Fail("password-length", "Password must be 8+ chars", ValidationOutcome.Fail.Severity.ERROR); return new ValidationOutcome.Pass("password-length"); }) .addRule(user -> { if (user.age() < 13) return new ValidationOutcome.Fail("age-minimum", "Must be 13 or older", ValidationOutcome.Fail.Severity.CRITICAL); return new ValidationOutcome.Pass("age-minimum"); }); UserRegistration user = new UserRegistration("", "invalid", "short", 10); ValidationReport report = pipeline.validate(user); report.printReport(); // Output: // FAIL [ERROR]: name-required - Name is required // FAIL [ERROR]: email-valid - Valid email required // FAIL [ERROR]: password-length - Password must be 8+ chars // FAIL [CRITICAL]: age-minimum - Must be 13 or older // Result: INVALID 11. Best Practices
Designing Sealed Hierarchies
- Start with the domain, not the code. Identify the concepts in your domain that have a fixed set of variants. Payments, events, results, commands, states -- these are natural candidates for sealed hierarchies.
- Keep hierarchies shallow. One level of sealing (parent + direct subtypes) covers 90% of use cases. Deeply nested sealed hierarchies are hard to navigate and maintain.
- Prefer sealed interfaces over sealed classes when subtypes do not share state. Interfaces allow records (which are great) and multiple implementation.
- Default to
finalfor permitted subclasses. Only usesealedornon-sealedwhen you have a specific reason.- Use records for data-carrying subtypes. The combination of sealed interfaces + records gives you immutable, well-defined algebraic data types with minimal boilerplate.
Permits Clause Rules
Rule Detail Same file If all permitted subclasses are in the same source file as the sealed class, the permitsclause is optional -- the compiler infers it.Same package If in different files, permitted subclasses must be in the same package as the sealed class. Same module In a modular project, permitted subclasses must be in the same module. Direct subtypes only The permitsclause lists only direct subtypes, not grandchildren.Every permitted subclass must extend A class listed in permitsmust extend the sealed class. It is a compile error if it does not.Common Mistakes to Avoid
- Over-sealing: Do not seal every class hierarchy. Seal only when the set of subtypes is truly fixed by your domain. If it is likely that new types will be added by users or plugins, keep the hierarchy open or use
non-sealedescape hatches.- Forgetting to handle new types: When you add a new permitted subclass, update all pattern matching methods. In Java 17 (using instanceof chains), the compiler will not catch this -- you need to be disciplined. In Java 21 (using switch expressions on sealed types), the compiler catches it.
- Using sealed classes for configuration: If your "types" are really just different configurations of the same behavior, use an enum or a single class with fields. Sealed classes are for structurally different types.
- Mixing sealed classes with reflection: Sealed classes constrain the compiler, not the runtime. Reflection can still create instances of non-permitted types. Do not rely on sealed classes for security.
Quick Reference
Feature Detail JEP JEP 409 -- finalized in Java 17 Keywords sealed,permits,non-sealedSubclass modifiers Must be final,sealed, ornon-sealedRecords as subtypes Yes -- records are implicitly final, perfect fit Enums as subtypes Yes -- enums are implicitly final Interfaces Interfaces can be sealedtoo, withpermitsPermits inference Permits clause optional when all subtypes are in the same file Exhaustive switch Preview in Java 17, finalized in Java 21 Reflection Class.getPermittedSubclasses()returns the permitted subtypesRelated features Records (Java 16), Pattern Matching instanceof (Java 16), Pattern Matching switch (Java 21)
Introduction to JavaJava VariablesJava Data TypesJava OperatorsJava StringJava Conditional StatementsJava For LoopJava MethodJava ClassJava InterfaceJava ArraysJava OOPException HandlingCollectionsPackagesStatic and Final keywordsJava Best PracticesDebuggingJava DateCode SnippetsJava – StreamJava – Lambda ExpressionJava – OptionalJava – CompletableFutureJava – Method ReferenceJava RecordJava Sealed Class