Sealed classes and interfaces, introduced in Java 15 (preview) and finalized in Java 17, provide a powerful mechanism for controlling inheritance hierarchies. They allow class and interface authors to explicitly declare which other classes or interfaces may extend or implement them.
1. Sealed Types Basics
What are Sealed Types?
- Sealed classes/interfaces restrict which classes can extend/implement them
- Provide explicit control over inheritance hierarchies
- Enable exhaustive pattern matching with switch expressions
- Improve code safety and maintainability
Basic Syntax
// Sealed class
public sealed class Shape
permits Circle, Rectangle, Triangle {
// Common shape methods
}
// Sealed interface
public sealed interface Expr
permits ConstantExpr, PlusExpr, MinusExpr {
// Common expression methods
}
2. Sealed Classes
Basic Sealed Class Hierarchy
// Sealed class with permitted subclasses
public sealed class Shape
permits Circle, Rectangle, Triangle {
private final String color;
public Shape(String color) {
this.color = color;
}
public String getColor() {
return color;
}
public abstract double area();
public abstract double perimeter();
}
// Final subclass - cannot be extended further
public final class Circle extends Shape {
private final double radius;
public Circle(String color, double radius) {
super(color);
this.radius = radius;
}
public double getRadius() {
return radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
}
// Non-sealed subclass - can be extended by any class
public non-sealed class Rectangle extends Shape {
private final double width;
private final double height;
public Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}
public double getWidth() {
return width;
}
public double getHeight() {
return height;
}
@Override
public double area() {
return width * height;
}
@Override
public double perimeter() {
return 2 * (width + height);
}
}
// Sealed subclass - can only be extended by permitted classes
public sealed class Triangle extends Shape
permits EquilateralTriangle, RightTriangle {
protected final double sideA, sideB, sideC;
public Triangle(String color, double sideA, double sideB, double sideC) {
super(color);
this.sideA = sideA;
this.sideB = sideB;
this.sideC = sideC;
}
@Override
public double area() {
// Heron's formula
double s = perimeter() / 2;
return Math.sqrt(s * (s - sideA) * (s - sideB) * (s - sideC));
}
@Override
public double perimeter() {
return sideA + sideB + sideC;
}
}
// Subclasses of sealed Triangle
public final class EquilateralTriangle extends Triangle {
public EquilateralTriangle(String color, double side) {
super(color, side, side, side);
}
}
public final class RightTriangle extends Triangle {
public RightTriangle(String color, double base, double height) {
super(color, base, height, Math.sqrt(base * base + height * height));
}
}
Sealed Class with Companion in Same File
// When all subclasses are in the same file, permits clause is optional
public sealed class Vehicle {
public abstract int getWheelCount();
}
// Permitted subclasses in same file - no explicit permits needed
final class Car extends Vehicle {
@Override
public int getWheelCount() {
return 4;
}
}
final class Motorcycle extends Vehicle {
@Override
public int getWheelCount() {
return 2;
}
}
final class Truck extends Vehicle {
@Override
public int getWheelCount() {
return 6;
}
}
3. Sealed Interfaces
Sealed Interface Hierarchy
// Sealed interface for mathematical expressions
public sealed interface MathExpression
permits Constant, Variable, Addition, Subtraction, Multiplication, Division {
double evaluate();
String toString();
}
// Simple constant expression
public final record Constant(double value) implements MathExpression {
@Override
public double evaluate() {
return value;
}
@Override
public String toString() {
return String.valueOf(value);
}
}
// Variable expression
public final record Variable(String name) implements MathExpression {
private static final Map<String, Double> variables = new HashMap<>();
static {
variables.put("x", 5.0);
variables.put("y", 3.0);
variables.put("pi", Math.PI);
}
@Override
public double evaluate() {
return variables.getOrDefault(name, 0.0);
}
@Override
public String toString() {
return name;
}
}
// Binary operations
public sealed interface BinaryOperation extends MathExpression
permits Addition, Subtraction, Multiplication, Division {
MathExpression left();
MathExpression right();
}
public record Addition(MathExpression left, MathExpression right)
implements BinaryOperation {
@Override
public double evaluate() {
return left.evaluate() + right.evaluate();
}
@Override
public String toString() {
return "(" + left + " + " + right + ")";
}
}
public record Subtraction(MathExpression left, MathExpression right)
implements BinaryOperation {
@Override
public double evaluate() {
return left.evaluate() - right.evaluate();
}
@Override
public String toString() {
return "(" + left + " - " + right + ")";
}
}
public record Multiplication(MathExpression left, MathExpression right)
implements BinaryOperation {
@Override
public double evaluate() {
return left.evaluate() * right.evaluate();
}
@Override
public String toString() {
return "(" + left + " * " + right + ")";
}
}
public record Division(MathExpression left, MathExpression right)
implements BinaryOperation {
@Override
public double evaluate() {
double denominator = right.evaluate();
if (denominator == 0) {
throw new ArithmeticException("Division by zero");
}
return left.evaluate() / denominator;
}
@Override
public String toString() {
return "(" + left + " / " + right + ")";
}
}
4. Pattern Matching with Sealed Types
Exhaustive Switch Expressions
public class ShapeProcessor {
// Exhaustive pattern matching with sealed classes
public static String describeShape(Shape shape) {
return switch (shape) {
case Circle c -> String.format("Circle with radius %.2f", c.getRadius());
case Rectangle r -> String.format("Rectangle %dx%d",
(int)r.getWidth(), (int)r.getHeight());
case EquilateralTriangle t -> "Equilateral Triangle";
case RightTriangle t -> "Right Triangle";
// No default needed - compiler knows all cases are covered
};
}
// Pattern matching with type patterns and guards
public static double calculateWithGuard(Shape shape) {
return switch (shape) {
case Circle c when c.getRadius() > 10 -> c.area() * 0.9; // Discount for large circles
case Circle c -> c.area();
case Rectangle r when r.getWidth() == r.getHeight() -> {
System.out.println("It's a square!");
yield r.area();
}
case Rectangle r -> r.area();
case Triangle t -> t.area();
};
}
// Nested pattern matching
public static String analyzeMathExpression(MathExpression expr) {
return switch (expr) {
case Constant c -> "Constant: " + c.value();
case Variable v -> "Variable: " + v.name();
case Addition(Constant left, Constant right) ->
"Addition of constants: " + (left.value() + right.value());
case Addition add -> "Addition: " + add;
case Subtraction sub -> "Subtraction: " + sub;
case Multiplication mul -> "Multiplication: " + mul;
case Division div -> "Division: " + div;
};
}
// Complex pattern matching with records
public static String optimizeExpression(MathExpression expr) {
return switch (expr) {
case Addition(Constant left, Constant right) ->
new Constant(left.value() + right.value()).toString();
case Multiplication(Constant left, Constant right) ->
new Constant(left.value() * right.value()).toString();
case Addition(Constant c, Variable v) when c.value() == 0 ->
v.toString(); // x + 0 = x
case Addition(Variable v, Constant c) when c.value() == 0 ->
v.toString(); // 0 + x = x
case Multiplication(Constant c, Variable v) when c.value() == 1 ->
v.toString(); // 1 * x = x
case Multiplication(Variable v, Constant c) when c.value() == 1 ->
v.toString(); // x * 1 = x
case Multiplication(Constant c, _) when c.value() == 0 ->
"0"; // 0 * anything = 0
case default -> expr.toString();
};
}
}
Advanced Pattern Matching Examples
public class AdvancedPatternMatching {
// Deconstruction patterns with records
public static String analyzeBinaryOperation(BinaryOperation op) {
return switch (op) {
case Addition(MathExpression l, MathExpression r) ->
"Adding " + l + " and " + r;
case Subtraction(MathExpression l, MathExpression r) ->
"Subtracting " + r + " from " + l;
case Multiplication(MathExpression l, MathExpression r) ->
"Multiplying " + l + " and " + r;
case Division(MathExpression l, MathExpression r) ->
"Dividing " + l + " by " + r;
};
}
// Pattern matching with null handling
public static String safeShapeDescription(Shape shape) {
return switch (shape) {
case null -> "No shape provided";
case Circle c -> "Circle: radius=" + c.getRadius();
case Rectangle r -> "Rectangle: " + r.getWidth() + "x" + r.getHeight();
case Triangle t -> "Triangle with perimeter: " + t.perimeter();
};
}
// Using pattern matching in streams
public static List<String> processShapes(List<Shape> shapes) {
return shapes.stream()
.map(shape -> switch (shape) {
case Circle c -> "Round: " + c.area();
case Rectangle r -> "Angular: " + r.area();
case Triangle t -> "Pointy: " + t.area();
})
.collect(Collectors.toList());
}
}
5. Real-World Use Cases
Use Case 1: AST for Compiler
// Abstract Syntax Tree for a simple programming language
public sealed interface Statement
permits VariableDeclaration, Assignment, IfStatement, WhileLoop, ExpressionStatement {
void execute(Map<String, Object> context);
}
public sealed interface Expression
permits Literal, VariableReference, BinaryOperation, UnaryOperation {
Object evaluate(Map<String, Object> context);
}
// Statements
public record VariableDeclaration(String name, Expression initialValue) implements Statement {
@Override
public void execute(Map<String, Object> context) {
context.put(name, initialValue.evaluate(context));
}
}
public record Assignment(String variableName, Expression value) implements Statement {
@Override
public void execute(Map<String, Object> context) {
if (!context.containsKey(variableName)) {
throw new RuntimeException("Variable not declared: " + variableName);
}
context.put(variableName, value.evaluate(context));
}
}
public record IfStatement(Expression condition, Statement thenBranch, Statement elseBranch)
implements Statement {
@Override
public void execute(Map<String, Object> context) {
Boolean conditionResult = (Boolean) condition.evaluate(context);
if (conditionResult) {
thenBranch.execute(context);
} else if (elseBranch != null) {
elseBranch.execute(context);
}
}
}
public record WhileLoop(Expression condition, Statement body) implements Statement {
@Override
public void execute(Map<String, Object> context) {
while ((Boolean) condition.evaluate(context)) {
body.execute(context);
}
}
}
public record ExpressionStatement(Expression expression) implements Statement {
@Override
public void execute(Map<String, Object> context) {
expression.evaluate(context); // Evaluate but don't use result
}
}
// Expressions
public record Literal(Object value) implements Expression {
@Override
public Object evaluate(Map<String, Object> context) {
return value;
}
}
public record VariableReference(String name) implements Expression {
@Override
public Object evaluate(Map<String, Object> context) {
if (!context.containsKey(name)) {
throw new RuntimeException("Undefined variable: " + name);
}
return context.get(name);
}
}
public sealed interface BinaryOperation extends Expression
permits ArithmeticOperation, ComparisonOperation, LogicalOperation {
Expression left();
Expression right();
}
public record ArithmeticOperation(Expression left, String operator, Expression right)
implements BinaryOperation {
@Override
public Object evaluate(Map<String, Object> context) {
Number leftVal = (Number) left.evaluate(context);
Number rightVal = (Number) right.evaluate(context);
return switch (operator) {
case "+" -> leftVal.doubleValue() + rightVal.doubleValue();
case "-" -> leftVal.doubleValue() - rightVal.doubleValue();
case "*" -> leftVal.doubleValue() * rightVal.doubleValue();
case "/" -> leftVal.doubleValue() / rightVal.doubleValue();
default -> throw new RuntimeException("Unknown operator: " + operator);
};
}
}
public record ComparisonOperation(Expression left, String operator, Expression right)
implements BinaryOperation {
@Override
public Object evaluate(Map<String, Object> context) {
Comparable leftVal = (Comparable) left.evaluate(context);
Comparable rightVal = (Comparable) right.evaluate(context);
return switch (operator) {
case "==" -> leftVal.equals(rightVal);
case "!=" -> !leftVal.equals(rightVal);
case "<" -> leftVal.compareTo(rightVal) < 0;
case ">" -> leftVal.compareTo(rightVal) > 0;
case "<=" -> leftVal.compareTo(rightVal) <= 0;
case ">=" -> leftVal.compareTo(rightVal) >= 0;
default -> throw new RuntimeException("Unknown operator: " + operator);
};
}
}
public record LogicalOperation(Expression left, String operator, Expression right)
implements BinaryOperation {
@Override
public Object evaluate(Map<String, Object> context) {
Boolean leftVal = (Boolean) left.evaluate(context);
Boolean rightVal = (Boolean) right.evaluate(context);
return switch (operator) {
case "&&" -> leftVal && rightVal;
case "||" -> leftVal || rightVal;
default -> throw new RuntimeException("Unknown operator: " + operator);
};
}
}
public record UnaryOperation(String operator, Expression operand) implements Expression {
@Override
public Object evaluate(Map<String, Object> context) {
Object value = operand.evaluate(context);
return switch (operator) {
case "!" -> !(Boolean) value;
case "-" -> -( (Number) value).doubleValue();
default -> throw new RuntimeException("Unknown operator: " + operator);
};
}
}
AST Interpreter using Pattern Matching
public class ASTInterpreter {
public static Object evaluateExpression(Expression expr, Map<String, Object> context) {
return switch (expr) {
case Literal lit -> lit.value();
case VariableReference var -> {
if (!context.containsKey(var.name())) {
throw new RuntimeException("Undefined variable: " + var.name());
}
yield context.get(var.name());
}
case ArithmeticOperation op -> evaluateArithmetic(op, context);
case ComparisonOperation op -> evaluateComparison(op, context);
case LogicalOperation op -> evaluateLogical(op, context);
case UnaryOperation op -> evaluateUnary(op, context);
};
}
private static Object evaluateArithmetic(ArithmeticOperation op, Map<String, Object> context) {
Number left = (Number) evaluateExpression(op.left(), context);
Number right = (Number) evaluateExpression(op.right(), context);
return switch (op.operator()) {
case "+" -> left.doubleValue() + right.doubleValue();
case "-" -> left.doubleValue() - right.doubleValue();
case "*" -> left.doubleValue() * right.doubleValue();
case "/" -> {
if (right.doubleValue() == 0) throw new ArithmeticException("Division by zero");
yield left.doubleValue() / right.doubleValue();
}
default -> throw new RuntimeException("Unknown arithmetic operator: " + op.operator());
};
}
private static Object evaluateComparison(ComparisonOperation op, Map<String, Object> context) {
Comparable left = (Comparable) evaluateExpression(op.left(), context);
Comparable right = (Comparable) evaluateExpression(op.right(), context);
return switch (op.operator()) {
case "==" -> left.equals(right);
case "!=" -> !left.equals(right);
case "<" -> left.compareTo(right) < 0;
case ">" -> left.compareTo(right) > 0;
case "<=" -> left.compareTo(right) <= 0;
case ">=" -> left.compareTo(right) >= 0;
default -> throw new RuntimeException("Unknown comparison operator: " + op.operator());
};
}
public static void executeStatement(Statement stmt, Map<String, Object> context) {
switch (stmt) {
case VariableDeclaration decl -> {
Object value = evaluateExpression(decl.initialValue(), context);
context.put(decl.name(), value);
}
case Assignment assign -> {
if (!context.containsKey(assign.variableName())) {
throw new RuntimeException("Variable not declared: " + assign.variableName());
}
Object value = evaluateExpression(assign.value(), context);
context.put(assign.variableName(), value);
}
case IfStatement ifStmt -> {
Boolean condition = (Boolean) evaluateExpression(ifStmt.condition(), context);
if (condition) {
executeStatement(ifStmt.thenBranch(), context);
} else if (ifStmt.elseBranch() != null) {
executeStatement(ifStmt.elseBranch(), context);
}
}
case WhileLoop whileLoop -> {
while ((Boolean) evaluateExpression(whileLoop.condition(), context)) {
executeStatement(whileLoop.body(), context);
}
}
case ExpressionStatement exprStmt -> {
evaluateExpression(exprStmt.expression(), context);
}
}
}
}
6. Sealed Types with Generics
Generic Sealed Hierarchies
// Sealed interface with generics
public sealed interface Result<T>
permits Success, Failure, Loading {
T getValue();
boolean isSuccess();
boolean isFailure();
boolean isLoading();
}
public record Success<T>(T value) implements Result<T> {
@Override
public T getValue() {
return value;
}
@Override
public boolean isSuccess() {
return true;
}
@Override
public boolean isFailure() {
return false;
}
@Override
public boolean isLoading() {
return false;
}
}
public record Failure<T>(String message, Throwable cause) implements Result<T> {
@Override
public T getValue() {
throw new IllegalStateException("Cannot get value from Failure: " + message);
}
@Override
public boolean isSuccess() {
return false;
}
@Override
public boolean isFailure() {
return true;
}
@Override
public boolean isLoading() {
return false;
}
}
public record Loading<T>() implements Result<T> {
@Override
public T getValue() {
throw new IllegalStateException("Still loading");
}
@Override
public boolean isSuccess() {
return false;
}
@Override
public boolean isFailure() {
return false;
}
@Override
public boolean isLoading() {
return true;
}
}
// Using generic sealed types with pattern matching
public class ResultProcessor {
public static <T> String processResult(Result<T> result) {
return switch (result) {
case Success<T> s -> "Success: " + s.getValue();
case Failure<T> f -> "Failure: " + f.message();
case Loading<T> l -> "Loading...";
};
}
public static <T> T getOrElse(Result<T> result, T defaultValue) {
return switch (result) {
case Success<T> s -> s.getValue();
case Failure<T> f -> {
System.err.println("Error: " + f.message());
yield defaultValue;
}
case Loading<T> l -> {
System.out.println("Waiting for result...");
yield defaultValue;
}
};
}
}
7. Best Practices and Design Patterns
1. Visitor Pattern with Sealed Types
// Traditional visitor pattern replaced with pattern matching
public sealed interface Document
permits TextDocument, SpreadsheetDocument, PresentationDocument {
// No need for accept(Visitor) method with sealed types + pattern matching
}
public record TextDocument(String content, String format) implements Document {}
public record SpreadsheetDocument(List<List<String>> data, int rows, int columns) implements Document {}
public record PresentationDocument(List<String> slides, String theme) implements Document {}
public class DocumentProcessor {
public static void processDocument(Document doc) {
switch (doc) {
case TextDocument text -> processText(text);
case SpreadsheetDocument spreadsheet -> processSpreadsheet(spreadsheet);
case PresentationDocument presentation -> processPresentation(presentation);
}
}
private static void processText(TextDocument text) {
System.out.println("Processing text document: " + text.format());
// Text processing logic
}
private static void processSpreadsheet(SpreadsheetDocument spreadsheet) {
System.out.println("Processing spreadsheet: " + spreadsheet.rows() + "x" + spreadsheet.columns());
// Spreadsheet processing logic
}
private static void processPresentation(PresentationDocument presentation) {
System.out.println("Processing presentation with " + presentation.slides().size() + " slides");
// Presentation processing logic
}
}
2. State Machine with Sealed Types
// State machine for a vending machine
public sealed interface VendingState
permits ReadyState, SelectingState, PaymentState, DispensingState, OutOfServiceState {
default VendingState insertCoin() {
return this; // Default no-op
}
default VendingState selectItem(String item) {
return this; // Default no-op
}
default VendingState cancel() {
return new ReadyState();
}
}
public record ReadyState() implements VendingState {
@Override
public VendingState insertCoin() {
System.out.println("Coin inserted. Please select an item.");
return new SelectingState();
}
}
public record SelectingState() implements VendingState {
@Override
public VendingState selectItem(String item) {
System.out.println("Selected: " + item + ". Please make payment.");
return new PaymentState(item);
}
@Override
public VendingState cancel() {
System.out.println("Selection cancelled. Returning coin.");
return new ReadyState();
}
}
public record PaymentState(String selectedItem) implements VendingState {
@Override
public VendingState insertCoin() {
System.out.println("Payment received. Dispensing: " + selectedItem);
return new DispensingState(selectedItem);
}
}
public record DispensingState(String item) implements VendingState {
@Override
public VendingState cancel() {
System.out.println("Cannot cancel while dispensing.");
return this;
}
}
public record OutOfServiceState(String reason) implements VendingState {
@Override
public VendingState insertCoin() {
System.out.println("Machine out of service: " + reason);
return this;
}
@Override
public VendingState selectItem(String item) {
System.out.println("Machine out of service: " + reason);
return this;
}
}
// State machine processor
public class VendingMachine {
private VendingState currentState = new ReadyState();
public void insertCoin() {
currentState = currentState.insertCoin();
}
public void selectItem(String item) {
currentState = currentState.selectItem(item);
}
public void cancel() {
currentState = currentState.cancel();
}
public void setOutOfService(String reason) {
currentState = new OutOfServiceState(reason);
}
public String getCurrentState() {
return switch (currentState) {
case ReadyState s -> "Ready";
case SelectingState s -> "Selecting";
case PaymentState s -> "Payment for " + s.selectedItem();
case DispensingState s -> "Dispensing " + s.item();
case OutOfServiceState s -> "Out of Service: " + s.reason();
};
}
}
8. Migration and Compatibility
Migrating from Traditional Hierarchy
// Before: Traditional open hierarchy
public abstract class PaymentMethod {
public abstract void processPayment(double amount);
}
public class CreditCardPayment extends PaymentMethod {
public void processPayment(double amount) { /* ... */ }
}
public class PayPalPayment extends PaymentMethod {
public void processPayment(double amount) { /* ... */ }
}
// Anyone could extend PaymentMethod - no control!
// After: Sealed hierarchy
public sealed abstract class PaymentMethod
permits CreditCardPayment, PayPalPayment, BankTransferPayment {
public abstract void processPayment(double amount);
}
public final class CreditCardPayment extends PaymentMethod {
@Override
public void processPayment(double amount) {
System.out.println("Processing credit card payment: $" + amount);
}
}
public final class PayPalPayment extends PaymentMethod {
@Override
public void processPayment(double amount) {
System.out.println("Processing PayPal payment: $" + amount);
}
}
public non-sealed class BankTransferPayment extends PaymentMethod {
@Override
public void processPayment(double amount) {
System.out.println("Processing bank transfer: $" + amount);
}
}
// BankTransferPayment can be extended for specific bank implementations
public final class DomesticBankTransfer extends BankTransferPayment {
@Override
public void processPayment(double amount) {
System.out.println("Processing domestic bank transfer: $" + amount);
}
}
9. Common Patterns and Anti-Patterns
Good Practices
// ✅ Use sealed types for known, fixed hierarchies
public sealed interface Command
permits LoginCommand, LogoutCommand, PurchaseCommand, RefundCommand {}
// ✅ Use exhaustive pattern matching
public static String handleCommand(Command cmd) {
return switch (cmd) {
case LoginCommand login -> "Logging in...";
case LogoutCommand logout -> "Logging out...";
case PurchaseCommand purchase -> "Processing purchase...";
case RefundCommand refund -> "Processing refund...";
};
}
// ✅ Use non-sealed for extensible parts of hierarchy
public sealed class PaymentProcessor
permits CreditCardProcessor, PayPalProcessor {
// ...
}
public non-sealed class CreditCardProcessor extends PaymentProcessor {
// Can be extended for specific card types
}
public final class VisaProcessor extends CreditCardProcessor {
// Visa-specific processing
}
Anti-Patterns
// ❌ Don't use sealed types for widely extensible hierarchies
// public sealed class Animal permits Dog, Cat, Bird... // Too restrictive!
// ❌ Avoid overly deep sealed hierarchies
public sealed class A permits B {}
public sealed class B permits C {} // Complex and hard to maintain
public sealed class C permits D {}
// ❌ Don't forget to handle all cases in pattern matching
public static String badPatternMatching(Shape shape) {
return switch (shape) {
case Circle c -> "Circle";
case Rectangle r -> "Rectangle";
// Missing Triangle cases - compiler error (which is good!)
};
}
Summary
Sealed classes and interfaces provide:
- Controlled Inheritance - Explicit control over who can extend/implement
- Exhaustive Pattern Matching - Compiler checks for complete case coverage
- Better Domain Modeling - Accurately represent fixed hierarchies
- Improved Safety - Prevent unintended extensions
- Cleaner Code - Replace visitor pattern with simple pattern matching
Key benefits:
- Compiler-enforced hierarchy integrity
- Exhaustiveness checking in switch expressions
- Better API design and maintenance
- Enhanced code readability and safety
Sealed types work particularly well with records and pattern matching to create expressive, safe, and maintainable code for domain modeling, ASTs, state machines, and other fixed hierarchies.