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.