Java 17 Sealed Classes

1. Introduction

Sealed classes became a permanent feature in Java 17 (JEP 409) after two preview rounds in Java 15 and 16. They give you controlled inheritance — you declare exactly which classes can extend a parent, and the compiler enforces it. If you are not yet familiar with the fundamentals of sealed classes — the sealed keyword, the permits clause, and basic syntax — read the Java Sealed Class fundamentals post first. This post assumes you know the basics and focuses on why sealed classes were added to Java 17, how they integrate with records and pattern matching, and how to use them effectively in real-world domain modeling.

2. Why Sealed Classes Were Added to Java 17

To understand why sealed classes exist, you need to understand a problem that Java developers have battled for decades: uncontrolled inheritance.

When you write a class hierarchy in Java — say, a Shape class with Circle, Rectangle, and Triangle subclasses — you have no way to say “these three subclasses are the complete set.” Anyone can extend Shape with a Hexagon or BlobShape, and your carefully written code that processes shapes now has a type it does not know about. This causes real problems:

  • Incomplete switch/if-else chains — You handle Circle, Rectangle, and Triangle, but someone adds Pentagon and your code silently ignores it or falls through to a default case that masks the bug.
  • Defensive programming overhead — You write throw new IllegalArgumentException("Unknown shape") in every method that dispatches on type, cluttering your code with error handling for cases that should be structurally impossible.
  • No compiler help — The compiler cannot warn you when a new subclass is added because it has no way of knowing the set is supposed to be closed.
  • Broken domain invariants — In many real domains, the set of subtypes is inherently finite. A boolean is true or false. A traffic light is red, yellow, or green. An HTTP response is informational, success, redirect, client error, or server error. Before Java 17, you could not express this constraint in the type system.

Languages like Kotlin (sealed class), Scala (sealed trait), Rust (enum with data), and Haskell (algebraic data types) solved this years ago. Java 17 finally brought this capability to the JVM.

The Java 17 Solution

Sealed classes solve this by introducing a permits clause that explicitly lists every permitted subclass. The compiler enforces three guarantees:

Guarantee What It Means Why It Matters
Closed set of subtypes Only classes listed in permits can extend the sealed class No surprise subclasses breaking your assumptions
Every subclass must declare its inheritance contract Permitted subclasses must be final, sealed, or non-sealed You control how deep the hierarchy goes
Exhaustive pattern matching The compiler knows all possible subtypes and can verify completeness No more default branches hiding missing cases

3. sealed, non-sealed, and final

Every class that extends a sealed class must declare one of three modifiers: final, sealed, or non-sealed. This is not optional — the compiler requires it. Each modifier answers a different question about how the hierarchy continues below that subclass.

The Three Modifiers

Modifier Can Be Extended? When to Use Analogy
final No — the hierarchy stops here Leaf nodes. The subclass is a concrete, complete type with no further specialization needed. A dead end in a hallway — no more doors
sealed Yes, but only by its own permitted subclasses Intermediate nodes. The subclass has its own closed set of subtypes. A locked door that opens to another hallway with its own guest list
non-sealed Yes, by anyone Extension points. You want to allow open-ended inheritance from this branch. An open door — anyone can walk through
// A complete example showing all three modifiers
public sealed class Vehicle permits Car, Truck, Motorcycle {
    private final String vin;
    public Vehicle(String vin) { this.vin = vin; }
    public String getVin() { return vin; }
}

// FINAL: no further subclassing
// A Motorcycle is a Motorcycle. No SportMotorcycle, no Scooter extends this.
final class Motorcycle extends Vehicle {
    private final int engineCC;
    public Motorcycle(String vin, int engineCC) {
        super(vin);
        this.engineCC = engineCC;
    }
}

// SEALED: controlled further subclassing
// A Car can be Sedan or SUV, but nothing else.
sealed class Car extends Vehicle permits Sedan, SUV {
    private final int doors;
    public Car(String vin, int doors) {
        super(vin);
        this.doors = doors;
    }
}

final class Sedan extends Car {
    public Sedan(String vin) { super(vin, 4); }
}

final class SUV extends Car {
    private final boolean fourWheelDrive;
    public SUV(String vin, boolean fourWheelDrive) {
        super(vin, 4);
        this.fourWheelDrive = fourWheelDrive;
    }
}

// NON-SEALED: open for extension
// Anyone can create new Truck types -- it's an extension point
non-sealed class Truck extends Vehicle {
    private final double payloadTons;
    public Truck(String vin, double payloadTons) {
        super(vin);
        this.payloadTons = payloadTons;
    }
}

// These are allowed because Truck is non-sealed:
class SemiTruck extends Truck {
    public SemiTruck(String vin) { super(vin, 40.0); }
}

class PickupTruck extends Truck {
    public PickupTruck(String vin) { super(vin, 1.5); }
}

Decision Flowchart

When designing a sealed hierarchy, ask these questions for each permitted subclass:

  1. Is this subclass a leaf node with no further specialization? Use final.
  2. Does this subclass have its own known, fixed set of subtypes? Use sealed with its own permits clause.
  3. Do you want to allow anyone to extend this branch? Use non-sealed. This is the escape hatch when part of your hierarchy needs to remain open.

Rule of thumb: Start with final everywhere. Only loosen to sealed or non-sealed when you have a clear reason. This follows the principle of least privilege — grant the minimum access needed.

4. Sealed Classes with Records

Records and sealed classes are a match made in heaven. Records are implicitly final, which means they automatically satisfy the requirement for permitted subclasses. Together, they let you build algebraic data types in Java — a concept borrowed from functional programming languages.

An algebraic data type (ADT) is a type that is defined by the set of values it can hold. Think of it as a “this OR that” type. A Result is either a Success or a Failure. A Shape is either a Circle, a Rectangle, or a Triangle. Each variant can carry different data.

// Algebraic data type: Shape is Circle OR Rectangle OR Triangle
public sealed interface Shape permits Circle, Rectangle, Triangle {
    double area();
}

// Each record is a data carrier -- immutable, with auto-generated
// equals(), hashCode(), toString(), and accessor methods
record Circle(double radius) implements Shape {
    public double area() { return Math.PI * radius * radius; }
}

record Rectangle(double width, double height) implements Shape {
    public double area() { return width * height; }
}

record Triangle(double base, double height) implements Shape {
    public double area() { return 0.5 * base * height; }
}

// Usage
Shape shape = new Circle(5.0);
System.out.println(shape.area());  // 78.53981633974483
System.out.println(shape);         // Circle[radius=5.0] (auto toString)

Result Type Pattern

One of the most powerful patterns with sealed classes + records is the Result type — a way to represent operations that can either succeed or fail, without using exceptions for control flow:

// A generic Result type -- Success OR Failure
public sealed interface Result permits Result.Success, Result.Failure {

    record Success(T value) implements Result { }

    record Failure(String error, Exception cause) implements Result {
        // Convenience constructor without cause
        Failure(String error) {
            this(error, null);
        }
    }

    // Factory methods
    static  Result success(T value) {
        return new Success<>(value);
    }

    static  Result failure(String error) {
        return new Failure<>(error);
    }

    static  Result failure(String error, Exception cause) {
        return new Failure<>(error, cause);
    }
}

// Using the Result type
public class UserService {

    public Result findUser(String email) {
        if (email == null || email.isBlank()) {
            return Result.failure("Email cannot be blank");
        }
        try {
            User user = database.findByEmail(email);
            if (user == null) {
                return Result.failure("User not found: " + email);
            }
            return Result.success(user);
        } catch (Exception e) {
            return Result.failure("Database error", e);
        }
    }
}

// Processing the result with pattern matching
Result result = userService.findUser("alice@example.com");
if (result instanceof Result.Success success) {
    System.out.println("Found: " + success.value().getName());
} else if (result instanceof Result.Failure failure) {
    System.out.println("Error: " + failure.error());
}

Why Records + Sealed Classes Work So Well Together

Feature Records Provide Sealed Classes Provide Together
Immutability Fields are final, no setters Hierarchy is fixed Both data and structure are immutable
Boilerplate reduction Auto equals/hashCode/toString No need for visitor pattern Minimal code for maximum expressiveness
Pattern matching Record patterns (Java 21) Exhaustive type matching Full destructuring of sealed hierarchies
Finality Records are implicitly final Requires final/sealed/non-sealed Records automatically satisfy the requirement

5. Sealed Interfaces

Sealed interfaces follow the same rules as sealed classes, but they are often the better choice because Java supports multiple interface implementation but only single class inheritance. This gives your design more flexibility.

When to Use Sealed Interfaces vs Sealed Classes

Criteria Sealed Interface Sealed Class
Shared state (fields) No — interfaces cannot have instance fields Yes — subclasses inherit fields and constructors
Multiple inheritance A class can implement multiple sealed interfaces A class can extend only one sealed class
Default methods Yes — you can provide default implementations Yes — inherited methods
Permitted types Classes, records, enums, or other interfaces Classes only (not records, not enums)
Best for Defining behaviors or capabilities Defining shared structure with common state

Rule of thumb: Prefer sealed interfaces when your subtypes are primarily distinguished by what data they carry (use records). Prefer sealed classes when your subtypes need to share common state and behavior (use inherited fields and methods).

// Sealed interface -- permits records, classes, or other interfaces
public sealed interface Notification
    permits EmailNotification, SmsNotification, PushNotification, UrgentNotification {

    String message();
    String recipient();
}

// Records implementing the sealed interface
record EmailNotification(String recipient, String message, String subject)
    implements Notification { }

record SmsNotification(String recipient, String message)
    implements Notification { }

record PushNotification(String recipient, String message, String deviceToken)
    implements Notification { }

// A sealed interface can permit another sealed interface
sealed interface UrgentNotification extends Notification
    permits EmergencyAlert, SystemAlert {
}

record EmergencyAlert(String recipient, String message, int severityLevel)
    implements UrgentNotification { }

record SystemAlert(String recipient, String message, String systemName)
    implements UrgentNotification { }

// Processing all notification types
public void send(Notification notification) {
    if (notification instanceof EmailNotification email) {
        sendEmail(email.recipient(), email.subject(), email.message());
    } else if (notification instanceof SmsNotification sms) {
        sendSms(sms.recipient(), sms.message());
    } else if (notification instanceof PushNotification push) {
        sendPush(push.deviceToken(), push.message());
    } else if (notification instanceof EmergencyAlert alert) {
        triggerEmergencyProtocol(alert.severityLevel(), alert.message());
    } else if (notification instanceof SystemAlert sysAlert) {
        logSystemAlert(sysAlert.systemName(), sysAlert.message());
    }
}

Sealed Interfaces with Enums

Enums can also implement sealed interfaces, which is useful when some variants are simple constants (enums) and others carry data (records):

// Mixing enums and records under a sealed interface
public sealed interface HttpStatus
    permits HttpStatus.Info, HttpStatus.Success, HttpStatus.Redirect,
            HttpStatus.ClientError, HttpStatus.ServerError {

    int code();
    String reason();

    // Simple statuses as enum constants
    enum Info implements HttpStatus {
        CONTINUE(100, "Continue"),
        SWITCHING_PROTOCOLS(101, "Switching Protocols");

        private final int code;
        private final String reason;
        Info(int code, String reason) { this.code = code; this.reason = reason; }
        public int code() { return code; }
        public String reason() { return reason; }
    }

    enum Success implements HttpStatus {
        OK(200, "OK"),
        CREATED(201, "Created"),
        NO_CONTENT(204, "No Content");

        private final int code;
        private final String reason;
        Success(int code, String reason) { this.code = code; this.reason = reason; }
        public int code() { return code; }
        public String reason() { return reason; }
    }

    // Error statuses with extra detail as records
    record Redirect(int code, String reason, String location) implements HttpStatus { }

    record ClientError(int code, String reason, String detail) implements HttpStatus { }

    record ServerError(int code, String reason, String stackTrace) implements HttpStatus { }
}

6. Pattern Matching Integration

Sealed classes and pattern matching are designed to work together. When the compiler knows the complete set of subtypes (because the hierarchy is sealed), it can verify that your code handles every possible case. This is called exhaustive pattern matching, and it eliminates an entire category of bugs — the “I forgot to handle this case” bug.

Note: Pattern matching in switch was a preview feature in Java 17 (JEP 406) and became finalized in Java 21 (JEP 441). The examples below require Java 21 for production use, but they illustrate the design intent behind sealed classes in Java 17.

Exhaustive Switch with Sealed Classes

// Sealed hierarchy
public sealed interface Shape permits Circle, Rectangle, Triangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double base, double height) implements Shape { }

// Exhaustive switch -- NO default needed!
// The compiler knows Shape can only be Circle, Rectangle, or Triangle.
public double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // No default needed -- the compiler verifies all cases are covered
    };
}

// What happens when you add a new permitted subclass?
// If you add:  record Pentagon(double side) implements Shape { }
// Every switch that doesn't handle Pentagon will FAIL TO COMPILE.
// The compiler forces you to handle the new type everywhere.

Why No Default Is a Feature, Not a Bug

With unsealed types, you always need a default branch because someone could add a new subclass at any time. That default branch is a hiding place for bugs — when a new type is added, the code silently falls into the default case instead of failing loudly. Sealed classes eliminate the need for default, turning silent failures into compile-time errors.

Guards in Pattern Matching

// Pattern matching with guards (when clauses) -- Java 21
public String describe(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 100    -> "Large circle";
        case Circle c when c.radius() > 10     -> "Medium circle";
        case Circle c                           -> "Small circle";
        case Rectangle r when r.width() == r.height() -> "Square: " + r.width();
        case Rectangle r                        -> "Rectangle: " + r.width() + "x" + r.height();
        case Triangle t when t.base() == t.height()  -> "Isoceles-looking triangle";
        case Triangle t                         -> "Triangle: base=" + t.base();
    };
}

// Pattern matching with instanceof in Java 17 (works today)
public String describeJava17(Shape shape) {
    if (shape instanceof Circle c && c.radius() > 100) {
        return "Large circle";
    } else if (shape instanceof Circle c && c.radius() > 10) {
        return "Medium circle";
    } else if (shape instanceof Circle c) {
        return "Small circle";
    } else if (shape instanceof Rectangle r && r.width() == r.height()) {
        return "Square: " + r.width();
    } else if (shape instanceof Rectangle r) {
        return "Rectangle: " + r.width() + "x" + r.height();
    } else if (shape instanceof Triangle t) {
        return "Triangle: base=" + t.base();
    }
    throw new IllegalArgumentException("Unknown shape");  // still needed in Java 17
}

The Evolution Path

Java Version Feature Status
Java 15 Sealed classes (preview) JEP 360
Java 16 Sealed classes (second preview), Pattern matching for instanceof (final) JEP 397, JEP 394
Java 17 Sealed classes (final), Pattern matching in switch (preview) JEP 409, JEP 406
Java 21 Pattern matching in switch (final), Record patterns (final) JEP 441, JEP 440

Java 17 gives you the foundation — sealed classes and instanceof pattern matching. Java 21 completes the picture with switch pattern matching and record deconstruction. Designing your sealed hierarchies well in Java 17 means you are ready to take full advantage of these features when you upgrade.

7. Sealed Classes in Domain Modeling

Sealed classes are most powerful when used for domain modeling — representing the real-world concepts your application works with. In many domains, the set of possible types is inherently fixed and known at design time.

Example 1: Payment Types

// A payment is exactly one of these four types
public sealed interface Payment permits
    CreditCardPayment, DebitCardPayment, BankTransfer, DigitalWallet {

    double amount();
    String currency();
}

record CreditCardPayment(double amount, String currency,
                          String cardNumber, String expiryDate, int cvv)
    implements Payment { }

record DebitCardPayment(double amount, String currency,
                         String cardNumber, String pin)
    implements Payment { }

record BankTransfer(double amount, String currency,
                     String iban, String swift, String reference)
    implements Payment { }

record DigitalWallet(double amount, String currency,
                      String walletId, String provider) // "PayPal", "ApplePay", etc.
    implements Payment { }

// Processing payments -- the compiler guarantees you handle all types
public class PaymentProcessor {

    public PaymentReceipt process(Payment payment) {
        if (payment instanceof CreditCardPayment cc) {
            validateCreditCard(cc.cardNumber(), cc.expiryDate(), cc.cvv());
            return chargeCard(cc);
        } else if (payment instanceof DebitCardPayment dc) {
            validatePin(dc.pin());
            return chargeDebit(dc);
        } else if (payment instanceof BankTransfer bt) {
            validateIban(bt.iban());
            return initiateBankTransfer(bt);
        } else if (payment instanceof DigitalWallet dw) {
            return processWalletPayment(dw);
        }
        throw new AssertionError("Unreachable -- all Payment types handled");
    }

    // Fee calculation -- different for each payment type
    public double calculateFee(Payment payment) {
        if (payment instanceof CreditCardPayment cc) {
            return cc.amount() * 0.029 + 0.30;  // 2.9% + $0.30
        } else if (payment instanceof DebitCardPayment dc) {
            return dc.amount() * 0.015;          // 1.5% flat
        } else if (payment instanceof BankTransfer bt) {
            return 5.00;                          // flat fee
        } else if (payment instanceof DigitalWallet dw) {
            return dw.amount() * 0.025;          // 2.5%
        }
        throw new AssertionError("Unreachable");
    }
}

Example 2: AST Nodes (Abstract Syntax Tree)

Compilers and interpreters frequently model expressions as a tree. Each node type has different data, and the set of node types is fixed by the language grammar.

// An expression in a simple calculator language
public sealed interface Expr permits
    Expr.Literal, Expr.Variable, Expr.BinaryOp, Expr.UnaryOp, Expr.FunctionCall {

    record Literal(double value) implements Expr { }

    record Variable(String name) implements Expr { }

    record BinaryOp(Expr left, String operator, Expr right) implements Expr { }

    record UnaryOp(String operator, Expr operand) implements Expr { }

    record FunctionCall(String name, List args) implements Expr { }
}

// Evaluator using pattern matching
public class ExprEvaluator {
    private final Map variables;

    public ExprEvaluator(Map variables) {
        this.variables = variables;
    }

    public double evaluate(Expr expr) {
        if (expr instanceof Expr.Literal lit) {
            return lit.value();
        } else if (expr instanceof Expr.Variable v) {
            Double val = variables.get(v.name());
            if (val == null) throw new RuntimeException("Undefined: " + v.name());
            return val;
        } else if (expr instanceof Expr.BinaryOp bin) {
            double left = evaluate(bin.left());
            double right = evaluate(bin.right());
            return switch (bin.operator()) {
                case "+" -> left + right;
                case "-" -> left - right;
                case "*" -> left * right;
                case "/" -> {
                    if (right == 0) throw new ArithmeticException("Division by zero");
                    yield left / right;
                }
                default -> throw new RuntimeException("Unknown op: " + bin.operator());
            };
        } else if (expr instanceof Expr.UnaryOp unary) {
            double operand = evaluate(unary.operand());
            return switch (unary.operator()) {
                case "-" -> -operand;
                case "+" -> operand;
                default -> throw new RuntimeException("Unknown op: " + unary.operator());
            };
        } else if (expr instanceof Expr.FunctionCall fn) {
            return switch (fn.name()) {
                case "sqrt" -> Math.sqrt(evaluate(fn.args().get(0)));
                case "abs"  -> Math.abs(evaluate(fn.args().get(0)));
                case "max"  -> Math.max(
                    evaluate(fn.args().get(0)),
                    evaluate(fn.args().get(1))
                );
                default -> throw new RuntimeException("Unknown function: " + fn.name());
            };
        }
        throw new AssertionError("Unknown expression type");
    }
}

// Building and evaluating: (x + 2) * 3
Expr expr = new Expr.BinaryOp(
    new Expr.BinaryOp(
        new Expr.Variable("x"),
        "+",
        new Expr.Literal(2)
    ),
    "*",
    new Expr.Literal(3)
);
ExprEvaluator eval = new ExprEvaluator(Map.of("x", 5.0));
System.out.println(eval.evaluate(expr));  // 21.0

Example 3: Validation Result

// Validation that reports either success or detailed errors
public sealed interface ValidationResult
    permits ValidationResult.Valid, ValidationResult.Invalid {

    record Valid() implements ValidationResult { }

    record Invalid(List errors) implements ValidationResult {
        Invalid(String singleError) {
            this(List.of(singleError));
        }
    }

    // Factory methods
    static ValidationResult valid() { return new Valid(); }

    static ValidationResult invalid(String error) {
        return new Invalid(error);
    }

    static ValidationResult invalid(List errors) {
        return new Invalid(errors);
    }

    // Combine multiple validation results
    static ValidationResult combine(ValidationResult... results) {
        List allErrors = new ArrayList<>();
        for (ValidationResult r : results) {
            if (r instanceof Invalid inv) {
                allErrors.addAll(inv.errors());
            }
        }
        return allErrors.isEmpty() ? valid() : invalid(allErrors);
    }
}

// Using the validation result
public class UserValidator {

    public ValidationResult validate(String name, String email, int age) {
        return ValidationResult.combine(
            validateName(name),
            validateEmail(email),
            validateAge(age)
        );
    }

    private ValidationResult validateName(String name) {
        if (name == null || name.isBlank()) {
            return ValidationResult.invalid("Name is required");
        }
        if (name.length() < 2) {
            return ValidationResult.invalid("Name must be at least 2 characters");
        }
        return ValidationResult.valid();
    }

    private ValidationResult validateEmail(String email) {
        if (email == null || !email.contains("@")) {
            return ValidationResult.invalid("Valid email is required");
        }
        return ValidationResult.valid();
    }

    private ValidationResult validateAge(int age) {
        if (age < 0 || age > 150) {
            return ValidationResult.invalid("Age must be between 0 and 150");
        }
        return ValidationResult.valid();
    }
}

// Processing the result
UserValidator validator = new UserValidator();
ValidationResult result = validator.validate("", "invalid", -5);

if (result instanceof ValidationResult.Valid) {
    System.out.println("All good -- proceed");
} else if (result instanceof ValidationResult.Invalid inv) {
    System.out.println("Validation failed:");
    inv.errors().forEach(e -> System.out.println("  - " + e));
}
// Output:
// Validation failed:
//   - Name is required
//   - Valid email is required
//   - Age must be between 0 and 150

8. Sealed Classes vs Enums

Enums and sealed classes both represent a fixed set of types, but they serve different purposes. Understanding when to use each is important for clean design.

Criteria Enum Sealed Class/Interface
Instances Fixed set of singleton instances (RED, GREEN, BLUE) Unlimited instances, each can carry different data
Data per instance Same fields for all constants, set in constructor Different fields per subclass/record
Behavior per instance Can override methods per constant, but it gets awkward Each subclass has its own methods and behavior naturally
Inheritance Enums cannot extend classes (they implicitly extend java.lang.Enum) Full class hierarchy with inheritance
Serialization Built-in, name-based You implement it yourself
Switch exhaustiveness Supported since Java 14 (switch expressions) Supported in Java 21 (pattern matching switch)
values() method Yes — iterate over all instances No built-in equivalent
Best for Simple constants, status codes, flags Complex types with variant-specific data and behavior

The Decision Rule

Use an enum when every instance has the same structure (same fields, same interface) and the values are known singletons. Think: days of the week, card suits, log levels.

Use a sealed class/interface when different instances need different data. Think: payment methods (credit card needs card number, bank transfer needs IBAN), AST nodes (literal has a value, binary operation has left/right/operator).

// ENUM: All instances have the same structure
public enum LogLevel {
    DEBUG(0, "DEBUG"),
    INFO(1, "INFO"),
    WARN(2, "WARN"),
    ERROR(3, "ERROR"),
    FATAL(4, "FATAL");

    private final int severity;
    private final String label;

    LogLevel(int severity, String label) {
        this.severity = severity;
        this.label = label;
    }

    public boolean isAtLeast(LogLevel other) {
        return this.severity >= other.severity;
    }
}

// SEALED CLASS: Each variant carries DIFFERENT data
public sealed interface LogEntry permits
    LogEntry.TextLog, LogEntry.ExceptionLog, LogEntry.MetricLog {

    LocalDateTime timestamp();
    LogLevel level();

    record TextLog(LocalDateTime timestamp, LogLevel level, String message)
        implements LogEntry { }

    record ExceptionLog(LocalDateTime timestamp, LogLevel level,
                         String message, Throwable exception, String stackTrace)
        implements LogEntry { }

    record MetricLog(LocalDateTime timestamp, LogLevel level,
                      String metricName, double value, Map tags)
        implements LogEntry { }
}

// The enum tells you HOW SEVERE the log is.
// The sealed interface tells you WHAT KIND of log it is.
// They work together -- they are not competing concepts.

9. Sealed Classes vs Visitor Pattern

The visitor pattern has been the traditional Java answer to the question: “How do I add new operations to a class hierarchy without modifying the classes?” It works, but it is notoriously verbose and hard to understand. Sealed classes offer a cleaner alternative for many use cases.

The Visitor Pattern (Traditional Approach)

// Step 1: Define a visitor interface with a method per type
interface DocumentVisitor {
    T visit(Paragraph p);
    T visit(Heading h);
    T visit(Image img);
    T visit(CodeBlock cb);
}

// Step 2: Every document element needs an accept() method
interface DocumentElement {
     T accept(DocumentVisitor visitor);
}

class Paragraph implements DocumentElement {
    private final String text;
    Paragraph(String text) { this.text = text; }
    String getText() { return text; }
    public  T accept(DocumentVisitor v) { return v.visit(this); }
}

class Heading implements DocumentElement {
    private final String text;
    private final int level;
    Heading(String text, int level) { this.text = text; this.level = level; }
    String getText() { return text; }
    int getLevel() { return level; }
    public  T accept(DocumentVisitor v) { return v.visit(this); }
}

class Image implements DocumentElement {
    private final String url;
    private final String alt;
    Image(String url, String alt) { this.url = url; this.alt = alt; }
    String getUrl() { return url; }
    String getAlt() { return alt; }
    public  T accept(DocumentVisitor v) { return v.visit(this); }
}

class CodeBlock implements DocumentElement {
    private final String code;
    private final String language;
    CodeBlock(String code, String language) { this.code = code; this.language = language; }
    String getCode() { return code; }
    String getLanguage() { return language; }
    public  T accept(DocumentVisitor v) { return v.visit(this); }
}

// Step 3: Implement the visitor for each operation
class HtmlRenderer implements DocumentVisitor {
    public String visit(Paragraph p) { return "

" + p.getText() + "

"; } public String visit(Heading h) { return "" + h.getText() + ""; } public String visit(Image img) { return "\"""; } public String visit(CodeBlock cb) { return "
" + cb.getCode() + "

"; }
}

// Usage: element.accept(new HtmlRenderer())
// That is a LOT of boilerplate for what is conceptually a simple type dispatch.

The Sealed Classes Approach

// Same functionality, fraction of the code
public sealed interface DocumentElement permits Paragraph, Heading, Image, CodeBlock { }

record Paragraph(String text) implements DocumentElement { }
record Heading(String text, int level) implements DocumentElement { }
record Image(String url, String alt) implements DocumentElement { }
record CodeBlock(String code, String language) implements DocumentElement { }

// Operations are just methods that pattern-match on the sealed type
public class DocumentRenderer {

    public static String toHtml(DocumentElement element) {
        if (element instanceof Paragraph p) {
            return "

" + p.text() + "

"; } else if (element instanceof Heading h) { return "" + h.text() + ""; } else if (element instanceof Image img) { return "\"""; } else if (element instanceof CodeBlock cb) { return "
" + cb.code() + "

";
}
throw new AssertionError("Unknown element type");
}

public static String toMarkdown(DocumentElement element) {
if (element instanceof Paragraph p) {
return p.text() + "\n";
} else if (element instanceof Heading h) {
return "#".repeat(h.level()) + " " + h.text() + "\n";
} else if (element instanceof Image img) {
return "![" + img.alt() + "](" + img.url() + ")\n";
} else if (element instanceof CodeBlock cb) {
return "```" + cb.language() + "\n" + cb.code() + "\n```\n";
}
throw new AssertionError("Unknown element type");
}

public static int wordCount(DocumentElement element) {
if (element instanceof Paragraph p) {
return p.text().split("\\s+").length;
} else if (element instanceof Heading h) {
return h.text().split("\\s+").length;
} else if (element instanceof Image img) {
return 0;
} else if (element instanceof CodeBlock cb) {
return 0;
}
throw new AssertionError("Unknown element type");
}
}

// Usage: String html = DocumentRenderer.toHtml(element);
// No visitor interface, no accept() methods, no double dispatch.

Visitor vs Sealed Classes: Trade-offs

Aspect Visitor Pattern Sealed Classes + Pattern Matching
Adding a new operation Easy — implement a new visitor class Easy — write a new method
Adding a new type Hard — must update the visitor interface and ALL implementations Hard — must update all pattern matching methods. But the compiler flags incomplete sealed switches.
Boilerplate High — visitor interface, accept() on every class Low — just the sealed interface and records
Type safety on new types Compile-time (adding to visitor interface forces updates) Compile-time with sealed switch (Java 21), runtime otherwise
Readability Low — logic is spread across visitor methods High — all logic for one operation is in one place
Third-party types Works — visitor can be external Works — pattern matching is external

Bottom line: For most use cases, sealed classes + pattern matching is simpler, more readable, and less boilerplate than the visitor pattern. The visitor pattern still has its place for very complex cases where you need double dispatch or where the operation set grows independently from the type set, but those cases are rarer than many textbooks suggest.

10. Practical Examples

Let us walk through three complete, production-quality examples that demonstrate sealed classes in real-world scenarios.

Example 1: Expression Evaluator

A simple mathematical expression evaluator that supports basic arithmetic, variables, and function calls. This is a common interview question and a classic use case for sealed hierarchies.

import java.util.*;

// The expression hierarchy -- complete and closed
public sealed interface Expression permits
    Expression.Number, Expression.Var, Expression.Binary,
    Expression.Unary, Expression.Conditional {

    record Number(double value) implements Expression { }

    record Var(String name) implements Expression { }

    record Binary(Expression left, Op operator, Expression right)
        implements Expression {

        enum Op { ADD, SUB, MUL, DIV, MOD, POW }
    }

    record Unary(UnaryOp operator, Expression operand) implements Expression {
        enum UnaryOp { NEGATE, ABS, SQRT }
    }

    record Conditional(Expression condition, Expression then, Expression otherwise)
        implements Expression { }
}

// Evaluator
public class Calculator {
    private final Map variables;

    public Calculator(Map variables) {
        this.variables = Map.copyOf(variables);
    }

    public double evaluate(Expression expr) {
        if (expr instanceof Expression.Number num) {
            return num.value();

        } else if (expr instanceof Expression.Var v) {
            Double val = variables.get(v.name());
            if (val == null) throw new RuntimeException("Undefined variable: " + v.name());
            return val;

        } else if (expr instanceof Expression.Binary bin) {
            double left = evaluate(bin.left());
            double right = evaluate(bin.right());
            return switch (bin.operator()) {
                case ADD -> left + right;
                case SUB -> left - right;
                case MUL -> left * right;
                case DIV -> {
                    if (right == 0) throw new ArithmeticException("Division by zero");
                    yield left / right;
                }
                case MOD -> left % right;
                case POW -> Math.pow(left, right);
            };

        } else if (expr instanceof Expression.Unary unary) {
            double operand = evaluate(unary.operand());
            return switch (unary.operator()) {
                case NEGATE -> -operand;
                case ABS -> Math.abs(operand);
                case SQRT -> {
                    if (operand < 0) throw new ArithmeticException("Square root of negative");
                    yield Math.sqrt(operand);
                }
            };

        } else if (expr instanceof Expression.Conditional cond) {
            return evaluate(cond.condition()) != 0
                ? evaluate(cond.then())
                : evaluate(cond.otherwise());
        }
        throw new AssertionError("Unknown expression type");
    }

    // Pretty-print the expression tree
    public String format(Expression expr) {
        if (expr instanceof Expression.Number num) {
            return String.valueOf(num.value());
        } else if (expr instanceof Expression.Var v) {
            return v.name();
        } else if (expr instanceof Expression.Binary bin) {
            String op = switch (bin.operator()) {
                case ADD -> "+"; case SUB -> "-";
                case MUL -> "*"; case DIV -> "/";
                case MOD -> "%"; case POW -> "^";
            };
            return "(" + format(bin.left()) + " " + op + " " + format(bin.right()) + ")";
        } else if (expr instanceof Expression.Unary u) {
            return switch (u.operator()) {
                case NEGATE -> "-" + format(u.operand());
                case ABS -> "abs(" + format(u.operand()) + ")";
                case SQRT -> "sqrt(" + format(u.operand()) + ")";
            };
        } else if (expr instanceof Expression.Conditional c) {
            return "if(" + format(c.condition()) + ", " +
                   format(c.then()) + ", " + format(c.otherwise()) + ")";
        }
        throw new AssertionError("Unknown expression type");
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        // Build: sqrt(x^2 + y^2)  -- the distance from origin
        Expression expr = new Expression.Unary(
            Expression.Unary.UnaryOp.SQRT,
            new Expression.Binary(
                new Expression.Binary(
                    new Expression.Var("x"),
                    Expression.Binary.Op.POW,
                    new Expression.Number(2)
                ),
                Expression.Binary.Op.ADD,
                new Expression.Binary(
                    new Expression.Var("y"),
                    Expression.Binary.Op.POW,
                    new Expression.Number(2)
                )
            )
        );

        Calculator calc = new Calculator(Map.of("x", 3.0, "y", 4.0));
        System.out.println(calc.format(expr));     // sqrt(((x ^ 2.0) + (y ^ 2.0)))
        System.out.println(calc.evaluate(expr));   // 5.0
    }
}

Example 2: Payment Processor

import java.time.LocalDateTime;
import java.util.*;

// Payment hierarchy
public sealed interface Payment permits
    Payment.CreditCard, Payment.BankTransfer, Payment.Crypto {

    double amount();
    String currency();

    record CreditCard(double amount, String currency,
                       String cardNumber, String expiry)
        implements Payment {
        // Compact constructor for validation
        CreditCard {
            if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
            if (cardNumber.length() != 16) throw new IllegalArgumentException("Card number must be 16 digits");
        }
    }

    record BankTransfer(double amount, String currency,
                         String fromIban, String toIban, String reference)
        implements Payment {
        BankTransfer {
            if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
        }
    }

    record Crypto(double amount, String currency,
                   String walletAddress, String network)
        implements Payment {
        Crypto {
            if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
            if (!network.equals("ETH") && !network.equals("BTC") && !network.equals("SOL")) {
                throw new IllegalArgumentException("Unsupported network: " + network);
            }
        }
    }
}

// Transaction result
public sealed interface TransactionResult permits
    TransactionResult.Approved, TransactionResult.Declined, TransactionResult.Pending {

    record Approved(String transactionId, LocalDateTime timestamp) implements TransactionResult { }
    record Declined(String reason, String errorCode) implements TransactionResult { }
    record Pending(String transactionId, String statusUrl) implements TransactionResult { }
}

// Processor
public class PaymentProcessor {

    public TransactionResult process(Payment payment) {
        // Validate
        String validationError = validate(payment);
        if (validationError != null) {
            return new TransactionResult.Declined(validationError, "VALIDATION_FAILED");
        }

        // Process based on type
        if (payment instanceof Payment.CreditCard cc) {
            return processCreditCard(cc);
        } else if (payment instanceof Payment.BankTransfer bt) {
            return processBankTransfer(bt);
        } else if (payment instanceof Payment.Crypto crypto) {
            return processCrypto(crypto);
        }
        throw new AssertionError("Unknown payment type");
    }

    private String validate(Payment payment) {
        if (payment.amount() > 10_000 && payment instanceof Payment.CreditCard) {
            return "Credit card transactions limited to $10,000";
        }
        if (payment instanceof Payment.Crypto c && c.amount() > 50_000) {
            return "Crypto transactions limited to $50,000";
        }
        return null;  // valid
    }

    private TransactionResult processCreditCard(Payment.CreditCard cc) {
        // Simulate card processing
        String txId = "CC-" + UUID.randomUUID().toString().substring(0, 8);
        System.out.printf("Charging %s %.2f to card ending in %s%n",
            cc.currency(), cc.amount(), cc.cardNumber().substring(12));
        return new TransactionResult.Approved(txId, LocalDateTime.now());
    }

    private TransactionResult processBankTransfer(Payment.BankTransfer bt) {
        // Bank transfers are async -- always pending initially
        String txId = "BT-" + UUID.randomUUID().toString().substring(0, 8);
        System.out.printf("Initiating %s %.2f transfer from %s to %s%n",
            bt.currency(), bt.amount(), bt.fromIban(), bt.toIban());
        return new TransactionResult.Pending(txId, "/api/transfers/" + txId);
    }

    private TransactionResult processCrypto(Payment.Crypto crypto) {
        String txId = "CR-" + UUID.randomUUID().toString().substring(0, 8);
        System.out.printf("Sending %s %.4f to %s on %s network%n",
            crypto.currency(), crypto.amount(), crypto.walletAddress(), crypto.network());
        return new TransactionResult.Approved(txId, LocalDateTime.now());
    }

    // Generate receipt based on transaction result
    public String generateReceipt(Payment payment, TransactionResult result) {
        StringBuilder sb = new StringBuilder();
        sb.append("=== Payment Receipt ===\n");
        sb.append("Amount: ").append(payment.currency()).append(" ")
          .append(String.format("%.2f", payment.amount())).append("\n");

        if (payment instanceof Payment.CreditCard cc) {
            sb.append("Method: Credit Card ending in ").append(cc.cardNumber().substring(12)).append("\n");
        } else if (payment instanceof Payment.BankTransfer bt) {
            sb.append("Method: Bank Transfer (").append(bt.reference()).append(")\n");
        } else if (payment instanceof Payment.Crypto c) {
            sb.append("Method: Crypto (").append(c.network()).append(")\n");
        }

        if (result instanceof TransactionResult.Approved a) {
            sb.append("Status: APPROVED\n");
            sb.append("Transaction ID: ").append(a.transactionId()).append("\n");
            sb.append("Time: ").append(a.timestamp()).append("\n");
        } else if (result instanceof TransactionResult.Declined d) {
            sb.append("Status: DECLINED\n");
            sb.append("Reason: ").append(d.reason()).append("\n");
        } else if (result instanceof TransactionResult.Pending p) {
            sb.append("Status: PENDING\n");
            sb.append("Track at: ").append(p.statusUrl()).append("\n");
        }

        return sb.toString();
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        PaymentProcessor processor = new PaymentProcessor();

        Payment payment = new Payment.CreditCard(
            99.99, "USD", "4111111111111111", "12/25"
        );

        TransactionResult result = processor.process(payment);
        System.out.println(processor.generateReceipt(payment, result));
    }
}

Example 3: Validation Pipeline

import java.util.*;
import java.util.stream.*;

// A validation rule that returns a result
public sealed interface ValidationOutcome permits
    ValidationOutcome.Pass, ValidationOutcome.Fail, ValidationOutcome.Skip {

    record Pass(String ruleName) implements ValidationOutcome { }

    record Fail(String ruleName, String message, Severity severity)
        implements ValidationOutcome {
        enum Severity { WARNING, ERROR, CRITICAL }
    }

    record Skip(String ruleName, String reason) implements ValidationOutcome { }
}

// Validation pipeline
public class ValidationPipeline {
    private final List> rules = new ArrayList<>();

    @FunctionalInterface
    interface ValidationRule {
        ValidationOutcome validate(T item);
    }

    public ValidationPipeline addRule(ValidationRule rule) {
        rules.add(rule);
        return this;
    }

    public ValidationReport validate(T item) {
        List outcomes = rules.stream()
            .map(rule -> rule.validate(item))
            .collect(Collectors.toList());
        return new ValidationReport(outcomes);
    }
}

// Report that summarizes all outcomes
public class ValidationReport {
    private final List outcomes;

    public ValidationReport(List outcomes) {
        this.outcomes = List.copyOf(outcomes);
    }

    public boolean isValid() {
        return outcomes.stream().noneMatch(o ->
            o instanceof ValidationOutcome.Fail f
                && f.severity() == ValidationOutcome.Fail.Severity.ERROR
        ) && outcomes.stream().noneMatch(o ->
            o instanceof ValidationOutcome.Fail f
                && f.severity() == ValidationOutcome.Fail.Severity.CRITICAL
        );
    }

    public List getErrors() {
        return outcomes.stream()
            .filter(o -> o instanceof ValidationOutcome.Fail)
            .map(o -> {
                if (o instanceof ValidationOutcome.Fail f) {
                    return "[" + f.severity() + "] " + f.ruleName() + ": " + f.message();
                }
                return "";
            })
            .filter(s -> !s.isEmpty())
            .collect(Collectors.toList());
    }

    public void printReport() {
        System.out.println("=== Validation Report ===");
        for (ValidationOutcome outcome : outcomes) {
            if (outcome instanceof ValidationOutcome.Pass p) {
                System.out.println("  PASS: " + p.ruleName());
            } else if (outcome instanceof ValidationOutcome.Fail f) {
                System.out.println("  FAIL [" + f.severity() + "]: " +
                    f.ruleName() + " - " + f.message());
            } else if (outcome instanceof ValidationOutcome.Skip s) {
                System.out.println("  SKIP: " + s.ruleName() + " (" + s.reason() + ")");
            }
        }
        System.out.println("Result: " + (isValid() ? "VALID" : "INVALID"));
    }
}

// Usage: validating a user registration
record UserRegistration(String name, String email, String password, int age) { }

ValidationPipeline pipeline = new ValidationPipeline()
    .addRule(user -> {
        if (user.name() == null || user.name().isBlank())
            return new ValidationOutcome.Fail("name-required", "Name is required",
                ValidationOutcome.Fail.Severity.ERROR);
        return new ValidationOutcome.Pass("name-required");
    })
    .addRule(user -> {
        if (user.email() == null || !user.email().contains("@"))
            return new ValidationOutcome.Fail("email-valid", "Valid email required",
                ValidationOutcome.Fail.Severity.ERROR);
        return new ValidationOutcome.Pass("email-valid");
    })
    .addRule(user -> {
        if (user.password().length() < 8)
            return new ValidationOutcome.Fail("password-length", "Password must be 8+ chars",
                ValidationOutcome.Fail.Severity.ERROR);
        return new ValidationOutcome.Pass("password-length");
    })
    .addRule(user -> {
        if (user.age() < 13)
            return new ValidationOutcome.Fail("age-minimum", "Must be 13 or older",
                ValidationOutcome.Fail.Severity.CRITICAL);
        return new ValidationOutcome.Pass("age-minimum");
    });

UserRegistration user = new UserRegistration("", "invalid", "short", 10);
ValidationReport report = pipeline.validate(user);
report.printReport();

// Output:
//   FAIL [ERROR]: name-required - Name is required
//   FAIL [ERROR]: email-valid - Valid email required
//   FAIL [ERROR]: password-length - Password must be 8+ chars
//   FAIL [CRITICAL]: age-minimum - Must be 13 or older
// Result: INVALID

11. Best Practices

Designing Sealed Hierarchies

  1. Start with the domain, not the code. Identify the concepts in your domain that have a fixed set of variants. Payments, events, results, commands, states -- these are natural candidates for sealed hierarchies.
  2. Keep hierarchies shallow. One level of sealing (parent + direct subtypes) covers 90% of use cases. Deeply nested sealed hierarchies are hard to navigate and maintain.
  3. Prefer sealed interfaces over sealed classes when subtypes do not share state. Interfaces allow records (which are great) and multiple implementation.
  4. Default to final for permitted subclasses. Only use sealed or non-sealed when you have a specific reason.
  5. Use records for data-carrying subtypes. The combination of sealed interfaces + records gives you immutable, well-defined algebraic data types with minimal boilerplate.

Permits Clause Rules

Rule Detail
Same file If all permitted subclasses are in the same source file as the sealed class, the permits clause is optional -- the compiler infers it.
Same package If in different files, permitted subclasses must be in the same package as the sealed class.
Same module In a modular project, permitted subclasses must be in the same module.
Direct subtypes only The permits clause lists only direct subtypes, not grandchildren.
Every permitted subclass must extend A class listed in permits must extend the sealed class. It is a compile error if it does not.

Common Mistakes to Avoid

  • Over-sealing: Do not seal every class hierarchy. Seal only when the set of subtypes is truly fixed by your domain. If it is likely that new types will be added by users or plugins, keep the hierarchy open or use non-sealed escape hatches.
  • Forgetting to handle new types: When you add a new permitted subclass, update all pattern matching methods. In Java 17 (using instanceof chains), the compiler will not catch this -- you need to be disciplined. In Java 21 (using switch expressions on sealed types), the compiler catches it.
  • Using sealed classes for configuration: If your "types" are really just different configurations of the same behavior, use an enum or a single class with fields. Sealed classes are for structurally different types.
  • Mixing sealed classes with reflection: Sealed classes constrain the compiler, not the runtime. Reflection can still create instances of non-permitted types. Do not rely on sealed classes for security.

Quick Reference

Feature Detail
JEP JEP 409 -- finalized in Java 17
Keywords sealed, permits, non-sealed
Subclass modifiers Must be final, sealed, or non-sealed
Records as subtypes Yes -- records are implicitly final, perfect fit
Enums as subtypes Yes -- enums are implicitly final
Interfaces Interfaces can be sealed too, with permits
Permits inference Permits clause optional when all subtypes are in the same file
Exhaustive switch Preview in Java 17, finalized in Java 21
Reflection Class.getPermittedSubclasses() returns the permitted subtypes
Related features Records (Java 16), Pattern Matching instanceof (Java 16), Pattern Matching switch (Java 21)



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *