Imagine you own a VIP club. You do not let just anyone walk in — you have a guest list. Only people whose names appear on that list are allowed through the door. If someone’s name is not on the list, they cannot enter, no exceptions. A sealed class in Java works exactly like that guest list: it explicitly declares which classes are permitted to extend it, and no one else can.
Before sealed classes, Java gave you two extremes for controlling inheritance:
Sealed classes fill the gap between these two extremes. They give you controlled inheritance — you decide exactly which classes are allowed to extend, and the compiler enforces it.
| Modifier | Who Can Extend? | Analogy |
|---|---|---|
public class |
Anyone, from anywhere | Open park — everyone is welcome |
sealed class |
Only classes listed in the permits clause |
VIP club — guest list only |
final class |
Nobody | Vault — sealed shut permanently |
Sealed classes were introduced as a preview feature in Java 15 (JEP 360), refined in Java 16 (JEP 397), and became a permanent feature in Java 17 (JEP 409). They were introduced for several important reasons:
switch expression handles every case without needing a default branch.switch that does not handle the new type. No more forgotten cases hiding as runtime bugs.// Before sealed classes: no way to control who extends Shape
public class Shape { }
// Anyone can do this -- you have no control
class Hexagon extends Shape { }
class RandomShape extends Shape { }
// With sealed classes: you control exactly who can extend Shape
public sealed class Shape permits Circle, Rectangle, Triangle { }
// Only these three classes can extend Shape
final class Circle extends Shape { }
final class Rectangle extends Shape { }
final class Triangle extends Shape { }
// This will NOT compile:
// class Hexagon extends Shape { }
// Error: class is not allowed to extend sealed class 'Shape'
The syntax for sealed classes introduces two new keywords: sealed and permits. The sealed modifier goes on the parent class, and the permits clause lists every class that is allowed to extend it.
The general syntax is:
// Syntax: sealed class ClassName permits SubClass1, SubClass2, ... { }
public sealed class Vehicle permits Car, Truck, Motorcycle {
private String make;
private String model;
private int year;
public Vehicle(String make, String model, int year) {
this.make = make;
this.model = model;
this.year = year;
}
public String getMake() { return make; }
public String getModel() { return model; }
public int getYear() { return year; }
@Override
public String toString() {
return year + " " + make + " " + model;
}
}
There are several important rules that the compiler enforces:
| # | Rule | What Happens If Violated |
|---|---|---|
| 1 | The permits clause must list all direct subclasses |
Compile error — unlisted class cannot extend |
| 2 | Every permitted subclass must directly extend the sealed class | Compile error — indirect extension not counted |
| 3 | Every permitted subclass must declare one of: final, sealed, or non-sealed |
Compile error — subclass must declare its own extension policy |
| 4 | Permitted subclasses must be in the same module (or same package if no module) | Compile error — cannot permit classes from other modules/packages |
| 5 | Permitted subclasses cannot be anonymous or local classes | Compile error |
| 6 | If all permitted subclasses are in the same file, the permits clause can be omitted |
Compiler infers it from the file contents |
When all subclasses are declared in the same source file as the sealed class, you can omit the permits clause entirely. The compiler infers the list of permitted subclasses from the file.
// File: Shape.java
// All subclasses are in the same file, so 'permits' is optional
public sealed class Shape {
// permits is inferred: Circle, Rectangle, Triangle
}
final class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
public double area() { return Math.PI * radius * radius; }
}
final class Rectangle extends Shape {
private final double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
public double area() { return width * height; }
}
final class Triangle extends Shape {
private final double base, height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
public double area() { return 0.5 * base * height; }
}
Every class that extends a sealed class must declare its own extension policy. It must be one of three things: final, sealed, or non-sealed. This is a requirement, not a suggestion — the compiler enforces it.
Think of it as a chain of responsibility: the sealed parent class says “only these kids can inherit from me,” and then each kid must decide how it handles its own children.
| Modifier | Meaning | When to Use |
|---|---|---|
final |
No further extension allowed. The hierarchy stops here. | When the subclass is a concrete leaf node with no need for further specialization. |
sealed |
Further extension is allowed, but only to a specific set of sub-subclasses. | When you need a multi-level hierarchy but still want full control at every level. |
non-sealed |
Opens the hierarchy back up. Anyone can extend this class freely. | When a subclass represents a category that others should be able to extend (e.g., a plugin system). |
A final subclass is a dead end. No other class can extend it. This is the most common choice for sealed hierarchies because most leaf types do not need further specialization.
public sealed class Notification permits EmailNotification, SmsNotification, PushNotification {
private final String message;
private final String recipient;
public Notification(String message, String recipient) {
this.message = message;
this.recipient = recipient;
}
public String getMessage() { return message; }
public String getRecipient() { return recipient; }
}
// final -- nobody can extend EmailNotification
final class EmailNotification extends Notification {
private final String subject;
public EmailNotification(String message, String recipient, String subject) {
super(message, recipient);
this.subject = subject;
}
public String getSubject() { return subject; }
@Override
public String toString() {
return "Email to " + getRecipient() + ": [" + subject + "] " + getMessage();
}
}
// final -- nobody can extend SmsNotification
final class SmsNotification extends Notification {
private final String phoneNumber;
public SmsNotification(String message, String recipient, String phoneNumber) {
super(message, recipient);
this.phoneNumber = phoneNumber;
}
public String getPhoneNumber() { return phoneNumber; }
@Override
public String toString() {
return "SMS to " + phoneNumber + ": " + getMessage();
}
}
// final -- nobody can extend PushNotification
final class PushNotification extends Notification {
private final String deviceToken;
public PushNotification(String message, String recipient, String deviceToken) {
super(message, recipient);
this.deviceToken = deviceToken;
}
@Override
public String toString() {
return "Push to device " + deviceToken + ": " + getMessage();
}
}
A sealed subclass extends the sealed parent and is itself sealed, declaring its own set of permitted subclasses. This creates a multi-level hierarchy where every level is fully controlled.
// Level 1: sealed parent
public sealed class Account permits PersonalAccount, BusinessAccount {
private final String id;
private double balance;
public Account(String id, double balance) {
this.id = id;
this.balance = balance;
}
public String getId() { return id; }
public double getBalance() { return balance; }
protected void setBalance(double balance) { this.balance = balance; }
}
// Level 2: sealed subclass -- further controlled extension
sealed class PersonalAccount extends Account permits CheckingAccount, SavingsAccount {
private final String ownerName;
public PersonalAccount(String id, double balance, String ownerName) {
super(id, balance);
this.ownerName = ownerName;
}
public String getOwnerName() { return ownerName; }
}
// Level 3: final leaf nodes
final class CheckingAccount extends PersonalAccount {
private final double overdraftLimit;
public CheckingAccount(String id, double balance, String ownerName, double overdraftLimit) {
super(id, balance, ownerName);
this.overdraftLimit = overdraftLimit;
}
public double getOverdraftLimit() { return overdraftLimit; }
}
final class SavingsAccount extends PersonalAccount {
private final double interestRate;
public SavingsAccount(String id, double balance, String ownerName, double interestRate) {
super(id, balance, ownerName);
this.interestRate = interestRate;
}
public double getInterestRate() { return interestRate; }
}
// Level 2: final leaf -- no further specialization needed
final class BusinessAccount extends Account {
private final String companyName;
private final String taxId;
public BusinessAccount(String id, double balance, String companyName, String taxId) {
super(id, balance);
this.companyName = companyName;
this.taxId = taxId;
}
public String getCompanyName() { return companyName; }
public String getTaxId() { return taxId; }
}
A non-sealed subclass breaks out of the sealed hierarchy and allows unrestricted extension. Use this when one branch of your hierarchy needs to be open for extension — for example, when you want to allow plugins or user-defined types.
Warning: Once you use non-sealed, you lose the exhaustiveness guarantee for that branch. The compiler cannot know all subtypes of a non-sealed class, so switch expressions over the sealed parent will require a default branch if they need to handle unknown subtypes.
public sealed class Transport permits Train, Bus, CustomTransport {
private final String name;
private final int capacity;
public Transport(String name, int capacity) {
this.name = name;
this.capacity = capacity;
}
public String getName() { return name; }
public int getCapacity() { return capacity; }
}
// final -- no further extension
final class Train extends Transport {
private final int carriages;
public Train(String name, int capacity, int carriages) {
super(name, capacity);
this.carriages = carriages;
}
public int getCarriages() { return carriages; }
}
// final -- no further extension
final class Bus extends Transport {
private final String route;
public Bus(String name, int capacity, String route) {
super(name, capacity);
this.route = route;
}
public String getRoute() { return route; }
}
// non-sealed -- open for extension by anyone
non-sealed class CustomTransport extends Transport {
public CustomTransport(String name, int capacity) {
super(name, capacity);
}
}
// Now anyone can extend CustomTransport freely
class ElectricScooter extends CustomTransport {
private final int range;
public ElectricScooter(String name, int capacity, int range) {
super(name, capacity);
this.range = range;
}
public int getRange() { return range; }
}
class Rickshaw extends CustomTransport {
public Rickshaw(String name) {
super(name, 2);
}
}
Sealed classes are not limited to classes alone — interfaces can be sealed too. A sealed interface declares which classes or interfaces are permitted to implement or extend it. The syntax and rules are virtually identical to sealed classes.
Sealed interfaces are especially powerful because interfaces represent contracts. When you seal an interface, you are saying: “Here is the contract, and here is the exhaustive list of types that will ever fulfill it.”
// Sealed interface -- only these three implementations are allowed
public sealed interface Payment permits CreditCardPayment, DebitCardPayment, BankTransferPayment {
double getAmount();
String getCurrency();
String describe();
}
// Each implementation is final
final class CreditCardPayment implements Payment {
private final double amount;
private final String currency;
private final String cardNumber; // last 4 digits only
private final String cardHolder;
public CreditCardPayment(double amount, String currency, String cardNumber, String cardHolder) {
this.amount = amount;
this.currency = currency;
this.cardNumber = cardNumber;
this.cardHolder = cardHolder;
}
@Override public double getAmount() { return amount; }
@Override public String getCurrency() { return currency; }
@Override
public String describe() {
return "Credit Card payment of " + currency + " " + amount
+ " from card ending in " + cardNumber + " (" + cardHolder + ")";
}
}
final class DebitCardPayment implements Payment {
private final double amount;
private final String currency;
private final String bankName;
public DebitCardPayment(double amount, String currency, String bankName) {
this.amount = amount;
this.currency = currency;
this.bankName = bankName;
}
@Override public double getAmount() { return amount; }
@Override public String getCurrency() { return currency; }
@Override
public String describe() {
return "Debit Card payment of " + currency + " " + amount + " via " + bankName;
}
}
final class BankTransferPayment implements Payment {
private final double amount;
private final String currency;
private final String bankAccountNumber;
private final String routingNumber;
public BankTransferPayment(double amount, String currency, String bankAccountNumber, String routingNumber) {
this.amount = amount;
this.currency = currency;
this.bankAccountNumber = bankAccountNumber;
this.routingNumber = routingNumber;
}
@Override public double getAmount() { return amount; }
@Override public String getCurrency() { return currency; }
@Override
public String describe() {
return "Bank Transfer of " + currency + " " + amount
+ " to account " + bankAccountNumber + " (routing: " + routingNumber + ")";
}
}
A sealed interface can be permitted to extend another sealed interface, creating a multi-level hierarchy of interfaces.
// Top-level sealed interface
public sealed interface Shape permits Shape2D, Shape3D {
String name();
}
// Sealed sub-interface for 2D shapes
sealed interface Shape2D extends Shape permits Circle, Rectangle {
double area();
double perimeter();
}
// Sealed sub-interface for 3D shapes
sealed interface Shape3D extends Shape permits Sphere, Cube {
double volume();
double surfaceArea();
}
// 2D implementations
final class Circle implements Shape2D {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override public String name() { return "Circle"; }
@Override public double area() { return Math.PI * radius * radius; }
@Override public double perimeter() { return 2 * Math.PI * radius; }
}
final class Rectangle implements Shape2D {
private final double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override public String name() { return "Rectangle"; }
@Override public double area() { return width * height; }
@Override public double perimeter() { return 2 * (width + height); }
}
// 3D implementations
final class Sphere implements Shape3D {
private final double radius;
public Sphere(double radius) { this.radius = radius; }
@Override public String name() { return "Sphere"; }
@Override public double volume() { return (4.0 / 3.0) * Math.PI * Math.pow(radius, 3); }
@Override public double surfaceArea() { return 4 * Math.PI * radius * radius; }
}
final class Cube implements Shape3D {
private final double side;
public Cube(double side) { this.side = side; }
@Override public String name() { return "Cube"; }
@Override public double volume() { return Math.pow(side, 3); }
@Override public double surfaceArea() { return 6 * side * side; }
}
Records (introduced in Java 16) and sealed classes (Java 17) are a perfect combination. Records are implicitly final, which means they automatically satisfy the requirement that a permitted subclass must be final, sealed, or non-sealed. This pairing gives you immutable, data-carrying leaf nodes in your sealed hierarchy — exactly what you want for algebraic data types.
The combination of sealed interfaces with record implementations gives you:
// Sealed interface + records = algebraic data type
public sealed interface Shape
permits Circle, Rectangle, Triangle {
double area();
}
// Records are implicitly final -- perfect for sealed hierarchies
record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
record Triangle(double base, double height) implements Shape {
@Override
public double area() {
return 0.5 * base * height;
}
}
// Usage
public class ShapeDemo {
public static void main(String[] args) {
Shape circle = new Circle(5.0);
Shape rectangle = new Rectangle(4.0, 6.0);
Shape triangle = new Triangle(3.0, 8.0);
System.out.println(circle); // Circle[radius=5.0]
System.out.println(rectangle); // Rectangle[width=4.0, height=6.0]
System.out.println(triangle); // Triangle[base=3.0, height=8.0]
System.out.println("Circle area: " + circle.area()); // Circle area: 78.53981633974483
System.out.println("Rectangle area: " + rectangle.area()); // Rectangle area: 24.0
System.out.println("Triangle area: " + triangle.area()); // Triangle area: 12.0
}
}
One of the most practical uses of sealed interfaces with records is building a Result type — a value that represents either success or failure. This is a common pattern in functional programming languages like Rust (Result<T, E>) and Kotlin (Result<T>).
// A generic Result type using sealed interface + records public sealed interface Resultpermits Result.Success, Result.Failure { record Success (T value) implements Result { } record Failure (String errorMessage, Exception cause) implements Result { // Convenience constructor when there is no cause Failure(String errorMessage) { this(errorMessage, null); } } } // A service that returns Result instead of throwing exceptions class UserService { public static Result findUserEmail(int userId) { if (userId <= 0) { return new Result.Failure<>("Invalid user ID: " + userId); } if (userId == 42) { return new Result.Success<>("john.doe@example.com"); } return new Result.Failure<>("User not found with ID: " + userId); } public static void main(String[] args) { // Process the result using pattern matching (Java 21+) Result result = findUserEmail(42); switch (result) { case Result.Success s -> System.out.println("Found email: " + s.value()); case Result.Failure f -> System.out.println("Error: " + f.errorMessage()); } // Output: Found email: john.doe@example.com Result error = findUserEmail(999); switch (error) { case Result.Success s -> System.out.println("Found email: " + s.value()); case Result.Failure f -> System.out.println("Error: " + f.errorMessage()); } // Output: Error: User not found with ID: 999 } }
This is where sealed classes truly shine. Because the compiler knows every possible subtype of a sealed class or interface, it can verify that a switch expression is exhaustive — meaning it handles every case. If you forget one, the code will not compile. If you add a new permitted subclass later, the compiler will flag every switch that does not handle the new type. No more “I forgot to handle that case” bugs discovered at 2 AM in production.
When you use a sealed type in a switch expression, you do not need a default branch — the compiler knows all possibilities. This is a massive improvement over traditional polymorphism, where you would need either a default or a Visitor pattern to ensure completeness.
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 { }
public class PatternMatchingDemo {
// Exhaustive switch -- no default needed!
// The compiler knows Shape can only be Circle, Rectangle, or Triangle.
public static double calculateArea(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 -- all cases are covered
};
}
public static String describe(Shape shape) {
return switch (shape) {
case Circle c -> "A circle with radius " + c.radius();
case Rectangle r -> "A " + r.width() + " x " + r.height() + " rectangle";
case Triangle t -> "A triangle with base " + t.base() + " and height " + t.height();
};
}
public static void main(String[] args) {
Shape[] shapes = {
new Circle(5.0),
new Rectangle(4.0, 6.0),
new Triangle(3.0, 8.0)
};
for (Shape shape : shapes) {
System.out.println(describe(shape) + " -> area = " + calculateArea(shape));
}
// Output:
// A circle with radius 5.0 -> area = 78.53981633974483
// A 4.0 x 6.0 rectangle -> area = 24.0
// A triangle with base 3.0 and height 8.0 -> area = 12.0
}
}
Here is the real power: when you add a new permitted subclass to the sealed hierarchy, the compiler will produce errors at every switch that does not handle the new case. This is compile-time safety that was previously impossible without the Visitor pattern.
// Step 1: Original 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 { }
// Step 2: You add a new shape
// public sealed interface Shape permits Circle, Rectangle, Triangle, Pentagon { }
// record Pentagon(double side) implements Shape { }
// Step 3: The compiler immediately flags this method:
//
// public static double calculateArea(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();
// // COMPILE ERROR: "the switch expression does not cover all possible input values"
// // You MUST add: case Pentagon p -> ...
// };
// }
//
// This forces you to handle the new case everywhere. No forgotten cases!
Java 21 introduced guarded patterns using the when keyword. You can add conditions to pattern matching cases to create more specific branches within a type match.
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 { }
public class GuardedPatterns {
public static String classify(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() + " x " + r.height() + ")";
case Rectangle r -> "Rectangle (" + r.width() + " x " + r.height() + ")";
case Triangle t when t.base() == t.height() -> "Isoceles-ish triangle";
case Triangle t -> "Triangle";
};
}
public static void main(String[] args) {
System.out.println(classify(new Circle(5))); // Small circle
System.out.println(classify(new Circle(50))); // Medium circle
System.out.println(classify(new Circle(200))); // Large circle
System.out.println(classify(new Rectangle(5, 5))); // Square (5.0 x 5.0)
System.out.println(classify(new Rectangle(4, 7))); // Rectangle (4.0 x 7.0)
System.out.println(classify(new Triangle(6, 6))); // Isoceles-ish triangle
}
}
Sealed classes also work with instanceof pattern matching (Java 16+). While switch is usually more concise, instanceof is useful when you need to check a single type in an if statement.
public sealed interface Event permits ClickEvent, KeyEvent, ScrollEvent { }
record ClickEvent(int x, int y, String button) implements Event { }
record KeyEvent(char key, boolean ctrlPressed) implements Event { }
record ScrollEvent(int delta) implements Event { }
public class InstanceofDemo {
public static void handleEvent(Event event) {
// instanceof with pattern matching
if (event instanceof ClickEvent click) {
System.out.println("Mouse clicked at (" + click.x() + ", " + click.y()
+ ") with " + click.button() + " button");
} else if (event instanceof KeyEvent key && key.ctrlPressed()) {
System.out.println("Ctrl+" + key.key() + " pressed -- shortcut detected!");
} else if (event instanceof KeyEvent key) {
System.out.println("Key pressed: " + key.key());
} else if (event instanceof ScrollEvent scroll) {
String direction = scroll.delta() > 0 ? "up" : "down";
System.out.println("Scrolled " + direction + " by " + Math.abs(scroll.delta()) + " units");
}
}
public static void main(String[] args) {
handleEvent(new ClickEvent(100, 200, "left"));
// Output: Mouse clicked at (100, 200) with left button
handleEvent(new KeyEvent('S', true));
// Output: Ctrl+S pressed -- shortcut detected!
handleEvent(new KeyEvent('A', false));
// Output: Key pressed: A
handleEvent(new ScrollEvent(-3));
// Output: Scrolled down by 3 units
}
}
At first glance, sealed classes and enums seem to solve the same problem: representing a fixed set of types. But they serve different purposes and have different strengths. Understanding when to use each is important.
| Feature | Enum | Sealed Class/Interface |
|---|---|---|
| Number of instances | Fixed set of singleton constants | Unlimited instances of each subtype |
| Data per instance | Same fields for all constants (set at declaration) | Different fields per subtype (set at construction) |
| Inheritance | Cannot extend other classes (implicitly extends Enum) | Can extend classes and implement interfaces |
| Behavior per variant | Can override methods per constant (anonymous class body) | Each subclass has its own methods and fields |
| Pattern matching | Match on constant values | Match on type + deconstruct data from fields |
| Generics | Cannot be parameterized | Can use generics freely |
| Serialization | Built-in (name-based) | Standard Java serialization rules apply |
| When to use | Simple, fixed set of related constants | Fixed set of types, each carrying different data |
The fundamental question is: does each variant carry different data?
// USE AN ENUM: all variants have the same structure (or no data)
public enum Direction {
NORTH, SOUTH, EAST, WEST;
public Direction opposite() {
return switch (this) {
case NORTH -> SOUTH;
case SOUTH -> NORTH;
case EAST -> WEST;
case WEST -> EAST;
};
}
}
// USE AN ENUM: simple status with same fields
public enum OrderStatus {
PENDING("Order placed"),
PROCESSING("Being prepared"),
SHIPPED("On the way"),
DELIVERED("Arrived");
private final String description;
OrderStatus(String description) { this.description = description; }
public String getDescription() { return description; }
}
// USE A SEALED TYPE: each variant carries DIFFERENT data
public sealed interface PaymentMethod permits CreditCard, PayPal, Crypto { }
record CreditCard(String cardNumber, String expiry, String cvv) implements PaymentMethod { }
record PayPal(String email) implements PaymentMethod { }
record Crypto(String walletAddress, String coin) implements PaymentMethod { }
// Each variant has completely different fields.
// An enum cannot model this cleanly.
The Visitor pattern has long been the go-to solution in Java for adding operations to a type hierarchy without modifying the types themselves. However, the Visitor pattern is notoriously verbose: you need a Visitor interface, an accept() method on every type, and a concrete visitor class for every operation. Sealed classes combined with pattern matching give you the same compile-time exhaustiveness guarantee with far less ceremony.
// Step 1: Define the Visitor interface with a method per type interface ShapeVisitor{ R visitCircle(Circle circle); R visitRectangle(Rectangle rectangle); R visitTriangle(Triangle triangle); } // Step 2: Define the hierarchy with accept() on each type abstract class Shape { abstract R accept(ShapeVisitor visitor); } class Circle extends Shape { final double radius; Circle(double radius) { this.radius = radius; } @Override R accept(ShapeVisitor visitor) { return visitor.visitCircle(this); } } class Rectangle extends Shape { final double width, height; Rectangle(double width, double height) { this.width = width; this.height = height; } @Override R accept(ShapeVisitor visitor) { return visitor.visitRectangle(this); } } class Triangle extends Shape { final double base, height; Triangle(double base, double height) { this.base = base; this.height = height; } @Override R accept(ShapeVisitor visitor) { return visitor.visitTriangle(this); } } // Step 3: Implement a visitor for each operation class AreaCalculator implements ShapeVisitor { @Override public Double visitCircle(Circle c) { return Math.PI * c.radius * c.radius; } @Override public Double visitRectangle(Rectangle r) { return r.width * r.height; } @Override public Double visitTriangle(Triangle t) { return 0.5 * t.base * t.height; } } // Usage: Shape shape = new Circle(5); double area = shape.accept(new AreaCalculator()); // 78.539...
With sealed types and switch expressions, you achieve the same compile-time safety with dramatically less code. No visitor interface, no accept() methods, no boilerplate.
// The entire hierarchy in a few lines
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 { }
// Operations are just methods with switch expressions -- no Visitor needed
public class ShapeOperations {
public static 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();
};
}
public static double perimeter(Shape shape) {
return switch (shape) {
case Circle c -> 2 * Math.PI * c.radius();
case Rectangle r -> 2 * (r.width() + r.height());
case Triangle t -> t.base() + t.height()
+ Math.sqrt(t.base() * t.base() + t.height() * t.height());
};
}
public static String svg(Shape shape) {
return switch (shape) {
case Circle c -> " ";
case Rectangle r -> " ";
case Triangle t -> " ";
};
}
public static void main(String[] args) {
Shape shape = new Circle(10);
System.out.println("Area: " + area(shape)); // Area: 314.1592653589793
System.out.println("Perimeter: " + perimeter(shape)); // Perimeter: 62.83185307179586
System.out.println("SVG: " + svg(shape)); // SVG:
}
}
// Compare:
// Visitor pattern: ~50 lines of infrastructure + operation code
// Sealed + switch: ~30 lines total, including all three operations
// Same compile-time safety. Far less code.
Sealed classes are not just an academic exercise. They solve real problems in production code. Here are several practical use cases where sealed types improve code quality, safety, and readability.
An order in an e-commerce system moves through a fixed set of states. Each state carries different data. Sealed types model this perfectly.
import java.time.LocalDateTime;
public sealed interface OrderState
permits OrderState.Pending, OrderState.Confirmed, OrderState.Shipped,
OrderState.Delivered, OrderState.Cancelled {
record Pending(LocalDateTime createdAt) implements OrderState { }
record Confirmed(LocalDateTime confirmedAt, String paymentId) implements OrderState { }
record Shipped(LocalDateTime shippedAt, String trackingNumber, String carrier)
implements OrderState { }
record Delivered(LocalDateTime deliveredAt, String signedBy) implements OrderState { }
record Cancelled(LocalDateTime cancelledAt, String reason) implements OrderState { }
}
class OrderProcessor {
public static String getStatusMessage(OrderState state) {
return switch (state) {
case OrderState.Pending p ->
"Order placed on " + p.createdAt() + ". Awaiting confirmation.";
case OrderState.Confirmed c ->
"Order confirmed on " + c.confirmedAt() + ". Payment ID: " + c.paymentId();
case OrderState.Shipped s ->
"Shipped via " + s.carrier() + " on " + s.shippedAt()
+ ". Tracking: " + s.trackingNumber();
case OrderState.Delivered d ->
"Delivered on " + d.deliveredAt() + ". Signed by: " + d.signedBy();
case OrderState.Cancelled c ->
"Cancelled on " + c.cancelledAt() + ". Reason: " + c.reason();
};
}
public static boolean canCancel(OrderState state) {
return switch (state) {
case OrderState.Pending p -> true;
case OrderState.Confirmed c -> true; // can still cancel before shipping
case OrderState.Shipped s -> false;
case OrderState.Delivered d -> false;
case OrderState.Cancelled c -> false;
};
}
}
When building compilers, interpreters, or expression evaluators, you need to represent an Abstract Syntax Tree (AST). Each node type carries different data: a number literal has a value, a binary operation has an operator and two operands, a variable reference has a name. Sealed types make this elegant.
// AST for a simple arithmetic expression evaluator
public sealed interface Expr
permits Expr.Num, Expr.Add, Expr.Mul, Expr.Neg, Expr.Var {
record Num(double value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Mul(Expr left, Expr right) implements Expr { }
record Neg(Expr operand) implements Expr { }
record Var(String name) implements Expr { }
}
class ExprEvaluator {
private final java.util.Map variables;
public ExprEvaluator(java.util.Map variables) {
this.variables = variables;
}
public double evaluate(Expr expr) {
return switch (expr) {
case Expr.Num n -> n.value();
case Expr.Add a -> evaluate(a.left()) + evaluate(a.right());
case Expr.Mul m -> evaluate(m.left()) * evaluate(m.right());
case Expr.Neg neg -> -evaluate(neg.operand());
case Expr.Var v -> variables.getOrDefault(v.name(), 0.0);
};
}
public String prettyPrint(Expr expr) {
return switch (expr) {
case Expr.Num n -> String.valueOf(n.value());
case Expr.Add a -> "(" + prettyPrint(a.left()) + " + " + prettyPrint(a.right()) + ")";
case Expr.Mul m -> "(" + prettyPrint(m.left()) + " * " + prettyPrint(m.right()) + ")";
case Expr.Neg neg -> "(-" + prettyPrint(neg.operand()) + ")";
case Expr.Var v -> v.name();
};
}
public static void main(String[] args) {
// Expression: (x + 2) * (y + 3)
Expr expression = new Expr.Mul(
new Expr.Add(new Expr.Var("x"), new Expr.Num(2)),
new Expr.Add(new Expr.Var("y"), new Expr.Num(3))
);
var variables = java.util.Map.of("x", 5.0, "y", 7.0);
var evaluator = new ExprEvaluator(variables);
System.out.println("Expression: " + evaluator.prettyPrint(expression));
// Output: Expression: ((x + 2) * (y + 3))
System.out.println("Result: " + evaluator.evaluate(expression));
// Output: Result: 70.0
}
}
In event-driven architectures, events represent things that happen in the system. Each event type carries different data, but the set of events is typically fixed and well-known within a bounded context.
import java.time.Instant;
public sealed interface DomainEvent
permits UserRegistered, OrderPlaced, PaymentReceived, ItemShipped {
Instant occurredAt();
}
record UserRegistered(String userId, String email, Instant occurredAt) implements DomainEvent { }
record OrderPlaced(String orderId, String userId, double total, Instant occurredAt) implements DomainEvent { }
record PaymentReceived(String paymentId, String orderId, double amount, Instant occurredAt) implements DomainEvent { }
record ItemShipped(String orderId, String trackingNumber, Instant occurredAt) implements DomainEvent { }
class EventLogger {
public static String formatEvent(DomainEvent event) {
return switch (event) {
case UserRegistered ur ->
"[USER] " + ur.occurredAt() + " - New user " + ur.email() + " (ID: " + ur.userId() + ")";
case OrderPlaced op ->
"[ORDER] " + op.occurredAt() + " - Order " + op.orderId()
+ " placed by " + op.userId() + " ($" + op.total() + ")";
case PaymentReceived pr ->
"[PAYMENT] " + pr.occurredAt() + " - Payment " + pr.paymentId()
+ " of $" + pr.amount() + " for order " + pr.orderId();
case ItemShipped is ->
"[SHIPPING] " + is.occurredAt() + " - Order " + is.orderId()
+ " shipped (tracking: " + is.trackingNumber() + ")";
};
}
public static void main(String[] args) {
var now = Instant.now();
DomainEvent[] events = {
new UserRegistered("U001", "jane@example.com", now),
new OrderPlaced("ORD-100", "U001", 149.99, now),
new PaymentReceived("PAY-50", "ORD-100", 149.99, now),
new ItemShipped("ORD-100", "1Z999AA10123456784", now)
};
for (DomainEvent event : events) {
System.out.println(formatEvent(event));
}
// Output:
// [USER] 2026-02-28T... - New user jane@example.com (ID: U001)
// [ORDER] 2026-02-28T... - Order ORD-100 placed by U001 ($149.99)
// [PAYMENT] 2026-02-28T... - Payment PAY-50 of $149.99 for order ORD-100
// [SHIPPING] 2026-02-28T... - Order ORD-100 shipped (tracking: 1Z999AA10123456784)
}
}
Sealed classes come with a specific set of restrictions enforced by the compiler. Understanding these rules prevents surprises.
| # | Rule | Explanation |
|---|---|---|
| 1 | All permitted subclasses must be listed | The permits clause must name every direct subclass. You cannot have a “hidden” subclass. |
| 2 | Same module or same package | If you use modules, all permitted subclasses must be in the same module. If no module system, they must be in the same package. |
| 3 | Direct extension only | Permitted classes must directly extend/implement the sealed type. You cannot list a grandchild class in the permits clause. |
| 4 | Must be final, sealed, or non-sealed | Every permitted subclass must declare one of these three modifiers. You cannot leave a subclass “plain.” |
| 5 | No anonymous classes | You cannot create an anonymous subclass of a sealed class. new Shape() { } is illegal if Shape is sealed. |
| 6 | No local classes | You cannot declare a subclass of a sealed class inside a method body. |
| 7 | Abstract sealed classes are allowed | A sealed class can be abstract. The permitted subclasses provide the concrete implementations. |
| 8 | Sealed + final is redundant | A class that is both sealed and final would permit subclasses but prevent them from existing. The compiler will warn about this. |
| 9 | permits clause cannot be empty | A sealed class must have at least one permitted subclass. Use final instead if you want zero subclasses. |
| 10 | Every listed class must actually extend | If you list Dog in permits, the Dog class must actually extend the sealed class. Otherwise, it is a compile error. |
// RULE 1: All subclasses must be listed
public sealed class Animal permits Dog, Cat { }
final class Dog extends Animal { }
final class Cat extends Animal { }
// final class Parrot extends Animal { } // COMPILE ERROR: not in permits list
// RULE 2: Same package (no modules)
// package com.example.animals;
// public sealed class Animal permits Dog, Cat { }
//
// package com.example.other;
// final class Dog extends Animal { } // COMPILE ERROR: different package
// RULE 4: Must declare final, sealed, or non-sealed
// class Dog extends Animal { } // COMPILE ERROR: Dog must be final, sealed, or non-sealed
// RULE 5: No anonymous classes
// Animal a = new Animal() { }; // COMPILE ERROR: cannot create anonymous subclass
// RULE 6: No local classes
// void someMethod() {
// class LocalAnimal extends Animal { } // COMPILE ERROR
// }
// RULE 10: Listed class must actually extend
// sealed class Vehicle permits Car { }
// final class Car { } // COMPILE ERROR: Car does not extend Vehicle
Here are guidelines from real-world experience to help you use sealed classes effectively.
This is the most common and idiomatic pattern. Sealed interfaces define the closed set of types, and records provide immutable, boilerplate-free data carriers. This combination gives you algebraic data types in Java.
Sealed interfaces are more flexible than sealed classes because Java allows only single inheritance for classes but multiple implementation for interfaces. Unless you need shared state (fields) in the parent, prefer sealing an interface.
Sealed types were designed to work hand-in-hand with pattern matching. If you find yourself using instanceof chains or the Visitor pattern with sealed types, consider whether a switch expression would be simpler and safer.
Deep hierarchies (sealed class that permits a sealed class that permits another sealed class…) become hard to reason about. Two levels is comfortable. Three is the practical maximum. Beyond that, consider redesigning.
The non-sealed modifier breaks the exhaustiveness guarantee. Only use it when one branch of your hierarchy genuinely needs to be open for extension, such as a plugin system or third-party extension point.
Adding a new permitted subclass is safe because the compiler flags all incomplete switches. However, removing a permitted subclass requires changing all code that references it. Design your hierarchy to be stable.
Sealed classes work best for clean, simple hierarchies. Avoid combining them with deep abstract class chains, multiple interfaces, and complex generics. Simplicity is the point.
| Practice | Do | Do Not |
|---|---|---|
| Data modeling | sealed interface + record |
Sealed class with mutable fields |
| Extensibility | Prefer sealed interfaces | Sealed classes unless you need shared state |
| Operations on types | switch expression with pattern matching |
Visitor pattern or instanceof chains |
| Hierarchy depth | 1-2 levels | 3+ levels of sealed types |
| Opening up | Use non-sealed only when needed |
Defaulting to non-sealed out of convenience |
| Naming | Name the sealed type after the concept (e.g., Shape, Payment) |
Generic names like BaseType or AbstractData |
| Location | Keep all subtypes in the same file (if small) | Scattering subtypes across many files |
Let us put everything together in a realistic, production-style example. We will build a payment processing system that demonstrates sealed interfaces, records, pattern matching, guarded patterns, exhaustive switches, and the full power of these features working in concert.
This example models a payment gateway that accepts multiple payment methods, validates them, calculates fees, processes them, and generates receipts — all with compile-time safety guaranteeing that every payment type is handled everywhere.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
// ============================================================
// 1. SEALED INTERFACE: The payment method hierarchy
// ============================================================
public sealed interface PaymentMethod
permits PaymentMethod.CreditCard, PaymentMethod.DebitCard,
PaymentMethod.BankTransfer, PaymentMethod.DigitalWallet {
// Records are implicitly final -- perfect for sealed hierarchies
record CreditCard(String cardNumber, String cardHolder, String expiry, String cvv)
implements PaymentMethod { }
record DebitCard(String cardNumber, String cardHolder, String bankName)
implements PaymentMethod { }
record BankTransfer(String accountNumber, String routingNumber, String accountHolder)
implements PaymentMethod { }
record DigitalWallet(String provider, String email)
implements PaymentMethod { }
}
// ============================================================
// 2. SEALED INTERFACE: The processing result
// ============================================================
sealed interface ProcessingResult
permits ProcessingResult.Approved, ProcessingResult.Declined,
ProcessingResult.PendingReview {
record Approved(String transactionId, LocalDateTime processedAt) implements ProcessingResult { }
record Declined(String reason, String errorCode) implements ProcessingResult { }
record PendingReview(String reviewId, String reason) implements ProcessingResult { }
}
// ============================================================
// 3. PAYMENT VALIDATOR: Uses pattern matching with guards
// ============================================================
class PaymentValidator {
public static String validate(PaymentMethod method, double amount) {
if (amount <= 0) {
return "Amount must be positive";
}
return switch (method) {
case PaymentMethod.CreditCard cc when cc.cardNumber().length() != 16 ->
"Credit card number must be 16 digits";
case PaymentMethod.CreditCard cc when cc.cvv().length() != 3 ->
"CVV must be 3 digits";
case PaymentMethod.CreditCard cc ->
null; // valid
case PaymentMethod.DebitCard dc when dc.cardNumber().length() != 16 ->
"Debit card number must be 16 digits";
case PaymentMethod.DebitCard dc ->
null; // valid
case PaymentMethod.BankTransfer bt when bt.routingNumber().length() != 9 ->
"Routing number must be 9 digits";
case PaymentMethod.BankTransfer bt ->
null; // valid
case PaymentMethod.DigitalWallet dw when !dw.email().contains("@") ->
"Invalid email for digital wallet";
case PaymentMethod.DigitalWallet dw ->
null; // valid
};
}
}
// ============================================================
// 4. FEE CALCULATOR: Different fees per payment type
// ============================================================
class FeeCalculator {
public static double calculateFee(PaymentMethod method, double amount) {
return switch (method) {
case PaymentMethod.CreditCard cc -> amount * 0.029 + 0.30; // 2.9% + $0.30
case PaymentMethod.DebitCard dc -> amount * 0.015 + 0.25; // 1.5% + $0.25
case PaymentMethod.BankTransfer bt -> 1.50; // flat $1.50
case PaymentMethod.DigitalWallet dw -> amount * 0.025; // 2.5%
};
}
public static String getFeeDescription(PaymentMethod method) {
return switch (method) {
case PaymentMethod.CreditCard cc -> "2.9% + $0.30";
case PaymentMethod.DebitCard dc -> "1.5% + $0.25";
case PaymentMethod.BankTransfer bt -> "Flat $1.50";
case PaymentMethod.DigitalWallet dw -> "2.5%";
};
}
}
// ============================================================
// 5. PAYMENT PROCESSOR: Processes payments, returns results
// ============================================================
class PaymentProcessor {
public static ProcessingResult process(PaymentMethod method, double amount) {
String validationError = PaymentValidator.validate(method, amount);
if (validationError != null) {
return new ProcessingResult.Declined(validationError, "VALIDATION_ERROR");
}
double fee = FeeCalculator.calculateFee(method, amount);
double total = amount + fee;
// Simulate processing logic
return switch (method) {
case PaymentMethod.CreditCard cc when total > 10000 ->
new ProcessingResult.PendingReview(
UUID.randomUUID().toString().substring(0, 8),
"High-value credit card transaction requires review"
);
case PaymentMethod.BankTransfer bt when total > 50000 ->
new ProcessingResult.PendingReview(
UUID.randomUUID().toString().substring(0, 8),
"Large bank transfer requires compliance review"
);
case PaymentMethod.CreditCard cc ->
new ProcessingResult.Approved(
"CC-" + UUID.randomUUID().toString().substring(0, 8),
LocalDateTime.now()
);
case PaymentMethod.DebitCard dc ->
new ProcessingResult.Approved(
"DC-" + UUID.randomUUID().toString().substring(0, 8),
LocalDateTime.now()
);
case PaymentMethod.BankTransfer bt ->
new ProcessingResult.Approved(
"BT-" + UUID.randomUUID().toString().substring(0, 8),
LocalDateTime.now()
);
case PaymentMethod.DigitalWallet dw ->
new ProcessingResult.Approved(
"DW-" + UUID.randomUUID().toString().substring(0, 8),
LocalDateTime.now()
);
};
}
}
// ============================================================
// 6. RECEIPT GENERATOR: Exhaustive handling of all types
// ============================================================
class ReceiptGenerator {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String generateReceipt(PaymentMethod method, double amount, ProcessingResult result) {
StringBuilder sb = new StringBuilder();
sb.append("========================================\n");
sb.append(" PAYMENT RECEIPT\n");
sb.append("========================================\n");
// Payment method details (exhaustive switch)
sb.append("Payment Method: ").append(switch (method) {
case PaymentMethod.CreditCard cc ->
"Credit Card ending in " + cc.cardNumber().substring(12) + "\n"
+ " Card Holder: " + cc.cardHolder();
case PaymentMethod.DebitCard dc ->
"Debit Card ending in " + dc.cardNumber().substring(12) + "\n"
+ " Bank: " + dc.bankName();
case PaymentMethod.BankTransfer bt ->
"Bank Transfer\n"
+ " Account: ****" + bt.accountNumber().substring(bt.accountNumber().length() - 4) + "\n"
+ " Account Holder: " + bt.accountHolder();
case PaymentMethod.DigitalWallet dw ->
dw.provider() + " (" + dw.email() + ")";
}).append("\n");
double fee = FeeCalculator.calculateFee(method, amount);
sb.append(String.format("Amount: $%,.2f%n", amount));
sb.append(String.format("Fee (%s): $%,.2f%n", FeeCalculator.getFeeDescription(method), fee));
sb.append(String.format("Total: $%,.2f%n", amount + fee));
sb.append("----------------------------------------\n");
// Result details (exhaustive switch)
sb.append("Status: ").append(switch (result) {
case ProcessingResult.Approved a ->
"APPROVED\n"
+ " Transaction ID: " + a.transactionId() + "\n"
+ " Processed at: " + a.processedAt().format(FMT);
case ProcessingResult.Declined d ->
"DECLINED\n"
+ " Reason: " + d.reason() + "\n"
+ " Error Code: " + d.errorCode();
case ProcessingResult.PendingReview pr ->
"PENDING REVIEW\n"
+ " Review ID: " + pr.reviewId() + "\n"
+ " Reason: " + pr.reason();
}).append("\n");
sb.append("========================================\n");
return sb.toString();
}
}
// ============================================================
// 7. MAIN APPLICATION: Putting it all together
// ============================================================
class PaymentApp {
public static void main(String[] args) {
// Test various payment methods
List payments = List.of(
new PaymentMethod.CreditCard("4111111111111234", "Jane Smith", "12/27", "123"),
new PaymentMethod.DebitCard("5500000000001234", "Bob Johnson", "Chase Bank"),
new PaymentMethod.BankTransfer("123456789012", "021000021", "Alice Williams"),
new PaymentMethod.DigitalWallet("PayPal", "alice@example.com")
);
double[] amounts = { 99.99, 250.00, 1500.00, 45.50 };
for (int i = 0; i < payments.size(); i++) {
PaymentMethod method = payments.get(i);
double amount = amounts[i];
ProcessingResult result = PaymentProcessor.process(method, amount);
String receipt = ReceiptGenerator.generateReceipt(method, amount, result);
System.out.println(receipt);
}
// Test validation failure
System.out.println("--- Testing invalid payment ---");
PaymentMethod invalid = new PaymentMethod.CreditCard("123", "Bad Card", "01/25", "12");
ProcessingResult failed = PaymentProcessor.process(invalid, 50.0);
System.out.println(ReceiptGenerator.generateReceipt(invalid, 50.0, failed));
// Test high-value transaction (triggers review)
System.out.println("--- Testing high-value transaction ---");
PaymentMethod bigCharge = new PaymentMethod.CreditCard("4111111111115678", "CEO", "06/28", "999");
ProcessingResult review = PaymentProcessor.process(bigCharge, 15000.00);
System.out.println(ReceiptGenerator.generateReceipt(bigCharge, 15000.00, review));
}
}
// Sample output:
// ========================================
// PAYMENT RECEIPT
// ========================================
// Payment Method: Credit Card ending in 1234
// Card Holder: Jane Smith
// Amount: $99.99
// Fee (2.9% + $0.30): $3.20
// Total: $103.19
// ----------------------------------------
// Status: APPROVED
// Transaction ID: CC-a1b2c3d4
// Processed at: 2026-02-28 14:30:15
// ========================================
| # | Feature | Where It Appears |
|---|---|---|
| 1 | Sealed interface | PaymentMethod and ProcessingResult |
| 2 | Records as permitted types | All subtypes (CreditCard, Approved, etc.) |
| 3 | Exhaustive switch expressions | FeeCalculator, ReceiptGenerator, PaymentProcessor |
| 4 | Guarded patterns (when) |
PaymentValidator and high-value transaction check in PaymentProcessor |
| 5 | No default branch needed | Every switch expression covers all cases without default |
| 6 | Type safety | Adding a new PaymentMethod variant forces updates to every switch |
| 7 | Different data per subtype | Each payment method and result carries different fields |
| 8 | Nested records in sealed interface | Records declared inside the sealed interface for clean organization |
| 9 | Pattern deconstruction | Accessing record fields directly in switch arms (e.g., cc.cardNumber()) |
| 10 | Separation of concerns | Validator, FeeCalculator, Processor, ReceiptGenerator are separate classes |
If you were to add a fifth payment method (say, Cryptocurrency), you would add it to the permits clause, and the compiler would immediately flag every switch expression that needs to handle the new type. Zero chance of forgetting a case.
| Concept | Syntax / Key Point |
|---|---|
| Declare a sealed class | public sealed class Shape permits Circle, Rectangle { } |
| Declare a sealed interface | public sealed interface Payment permits CreditCard, DebitCard { } |
| final subclass | final class Circle extends Shape { } -- no further extension |
| sealed subclass | sealed class PersonalAccount extends Account permits Checking, Savings { } |
| non-sealed subclass | non-sealed class CustomTransport extends Transport { } -- opens hierarchy |
| Implicit permits | Omit permits when all subclasses are in the same source file |
| Records + sealed | record Circle(double r) implements Shape { } -- records are implicitly final |
| Exhaustive switch | switch (shape) { case Circle c -> ...; case Rectangle r -> ...; } -- no default needed |
| Guarded patterns | case Circle c when c.radius() > 100 -> "large" |
| Java version | Preview in Java 15-16, permanent in Java 17 (JEP 409) |
| Location rule | All permitted subclasses must be in the same module (or same package if no modules) |
| vs Enum | Use enums for fixed constants with same structure; sealed types for subtypes with different data |