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

required
required


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)
March 1, 2026

Java 17 Pattern Matching for instanceof

1. Introduction

If you have written Java for any length of time, you have written this pattern hundreds of times: check if an object is a certain type with instanceof, then immediately cast it to that type so you can use it. It works, but it is repetitive, error-prone, and makes your code harder to read.

Here is the classic pattern every Java developer knows by heart:

// The old way -- check, then cast, then use
if (obj instanceof String) {
    String s = (String) obj;  // redundant -- we JUST checked it's a String
    System.out.println(s.toUpperCase());
}

// More realistic example from real codebases
public void processShape(Object shape) {
    if (shape instanceof Circle) {
        Circle circle = (Circle) shape;
        double area = Math.PI * circle.getRadius() * circle.getRadius();
        System.out.println("Circle area: " + area);
    } else if (shape instanceof Rectangle) {
        Rectangle rect = (Rectangle) shape;
        double area = rect.getWidth() * rect.getHeight();
        System.out.println("Rectangle area: " + area);
    } else if (shape instanceof Triangle) {
        Triangle tri = (Triangle) shape;
        double area = 0.5 * tri.getBase() * tri.getHeight();
        System.out.println("Triangle area: " + area);
    }
}

Look at the processShape method. Every single branch does the same dance: instanceof check, cast to the type we already know it is, then use the casted variable. The cast on line 2 is completely redundant — the compiler already confirmed the type on line 1. Multiply this across a large codebase, and you get thousands of lines of boilerplate that add nothing but visual noise and opportunities for bugs.

What could go wrong with the old pattern?

  • Copy-paste bugs — You check for Circle but accidentally cast to Rectangle. The compiler cannot catch this because both casts are valid Object-to-subtype operations. You get a ClassCastException at runtime.
  • Variable name pollution — You need separate variable names (circle, rect, tri) even though only one is ever used.
  • Three statements for one concept — “If this is a String, use it as a String” is one idea expressed in three lines of code.
  • Refactoring hazard — When refactoring, you might change the instanceof check but forget to update the cast, or vice versa.

Java 16 introduced Pattern Matching for instanceof (JEP 394), and it became a permanent feature in Java 17 as part of the LTS release. It eliminates this redundancy by letting you declare a pattern variable directly in the instanceof expression. The compiler handles the cast for you, and the variable is only in scope where it is safe to use.

Think of it like a customs officer at an airport. The old way: the officer checks your passport (instanceof), takes your passport away (cast), writes your name on a sticky note (new variable), and hands the sticky note back to you. The new way: the officer checks your passport and just lets you through — you are you, no sticky note needed.

Aspect Old Pattern (Pre-Java 16) Pattern Matching (Java 16+)
Lines of code 3 (check + cast + use) 1 (check + bind + use)
Redundant cast Yes — you manually cast after already checking No — compiler auto-casts
Type safety Cast can mismatch the check (runtime error) Impossible to mismatch (compile-time guarantee)
Variable scope Manually managed, lives in enclosing block Flow-scoped, only exists where type is guaranteed
JEP N/A JEP 394 (Java 16, finalized in Java 17)

2. Basic Pattern Matching

The new syntax is beautifully simple. Instead of writing instanceof Type and then casting, you write instanceof Type variableName. The compiler creates and casts the variable for you, and it is immediately available for use.

// Old way: three steps
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// New way: one step
if (obj instanceof String s) {
    System.out.println(s.length());  // s is already a String -- no cast needed
}

// The variable 's' is called a "pattern variable"
// It is automatically:
//   1. Declared (you don't write 'String s = ...')
//   2. Cast (the compiler handles the cast)
//   3. Scoped (it only exists where the instanceof is true)

The variable s in obj instanceof String s is called a pattern variable. It is a binding that the compiler creates for you when the pattern matches. You do not need to declare it separately, and you cannot accidentally cast to the wrong type because the declaration and the type check are a single atomic expression.

More Examples

// Example 1: Working with different number types
public static double toDouble(Object obj) {
    if (obj instanceof Integer i) {
        return i.doubleValue();     // i is Integer
    } else if (obj instanceof Double d) {
        return d;                    // d is Double
    } else if (obj instanceof Long l) {
        return l.doubleValue();     // l is Long
    } else if (obj instanceof String s) {
        return Double.parseDouble(s); // s is String
    }
    throw new IllegalArgumentException("Cannot convert: " + obj);
}

// Example 2: Null-safe usage
Object value = null;
if (value instanceof String s) {
    // This block NEVER executes when value is null
    // instanceof already returns false for null, so pattern matching
    // inherits this behavior -- no NullPointerException possible
    System.out.println(s.length());
}
System.out.println("Safe -- no NPE");

// Example 3: Using in a method that processes mixed collections
public static void printDetails(List items) {
    for (Object item : items) {
        if (item instanceof String s) {
            System.out.println("String of length " + s.length() + ": " + s);
        } else if (item instanceof Integer i) {
            System.out.println("Integer, even=" + (i % 2 == 0) + ": " + i);
        } else if (item instanceof List list) {
            System.out.println("List with " + list.size() + " elements");
        } else if (item instanceof Map map) {
            System.out.println("Map with " + map.size() + " entries");
        } else {
            System.out.println("Unknown type: " + item.getClass().getSimpleName());
        }
    }
}

Key point about null safety: Pattern matching for instanceof is inherently null-safe. If the object being tested is null, instanceof returns false, and the pattern variable is never bound. This means you can never get a NullPointerException from using a pattern variable inside the if block. This is a significant advantage over manual casting, where forgetting a null check is a common source of bugs.

3. Scope Rules

The scope of a pattern variable is one of the most important — and sometimes surprising — aspects of this feature. Pattern variables use flow scoping, which means the variable is only in scope where the compiler can guarantee the instanceof test succeeded. This is different from traditional block scoping.

Basic if-else Scope

public void demonstrateScope(Object obj) {
    // Pattern variable 's' is in scope ONLY inside the if-block
    if (obj instanceof String s) {
        // s is in scope here -- instanceof was true
        System.out.println(s.toUpperCase());
    } else {
        // s is NOT in scope here -- obj is NOT a String
        // System.out.println(s.length()); // COMPILE ERROR
        System.out.println("Not a string: " + obj);
    }
    // s is NOT in scope here either
    // System.out.println(s); // COMPILE ERROR
}

Negated instanceof (The “Else” Pattern)

Here is where flow scoping gets interesting. When you negate the instanceof check, the pattern variable flows into the else path — which, in this case, is everything after the if-block:

public void negatedScope(Object obj) {
    // Negated pattern: "if obj is NOT a String, exit early"
    if (!(obj instanceof String s)) {
        // s is NOT in scope here -- obj is NOT a String
        System.out.println("Not a string, returning");
        return;  // <-- early return is KEY
    }
    // s IS in scope here! Because if we reach this line,
    // the negated condition was false, meaning instanceof was TRUE
    System.out.println(s.toUpperCase());  // perfectly valid
    System.out.println(s.length());       // s is still in scope
}

// This is the "guard clause" pattern -- very common in real code
public String processInput(Object input) {
    if (!(input instanceof String s)) {
        throw new IllegalArgumentException("Expected String, got: " + input);
    }
    // s is in scope for the rest of the method
    return s.trim().toLowerCase();
}

The guard clause pattern is extremely powerful. It lets you validate the type at the top of a method and then use the pattern variable for the entire remaining method body without nesting everything inside an if-block. This leads to flatter, more readable code.

Scope in Loops

// Pattern variables in loops work as expected
public void processItems(List items) {
    for (Object item : items) {
        if (item instanceof String s) {
            // s is scoped to this iteration of the loop
            System.out.println("String: " + s.toUpperCase());
        }
        // s is out of scope here
    }
}

// Using the negated pattern with continue
public void processStringsOnly(List items) {
    for (Object item : items) {
        if (!(item instanceof String s)) {
            continue;  // skip non-strings
        }
        // s is in scope for the rest of this loop iteration
        System.out.println("Processing: " + s.trim());
    }
}

Flow Scoping Summary

Pattern Variable in Scope? Why
if (x instanceof String s) { ... } Inside the if-block only Compiler knows x is String in the if-block
if (!(x instanceof String s)) { return; } use(s); After the if-block If we pass the guard, x must be String
if (x instanceof String s) { } else { use(s); } NOT in the else-block x is NOT String in the else path
while (x instanceof String s) { ... } Inside the while body Loop body only runs when pattern matches

4. Pattern Matching with && and ||

You can combine pattern matching with logical operators, but there are important rules about which combinations work and which do not. Understanding these rules comes down to one question: can the compiler guarantee the pattern variable was bound?

Works: Pattern Matching with && (AND)

The && operator short-circuits: if the left side is false, the right side is never evaluated. This means if instanceof is on the left and passes, the pattern variable is guaranteed to be bound when the right side is evaluated.

// WORKS: instanceof on left, use pattern variable on right
if (obj instanceof String s && s.length() > 5) {
    System.out.println("Long string: " + s);
}
// Why this works:
// 1. If obj is not a String, && short-circuits, s.length() is never called
// 2. If obj IS a String, s is bound, and s.length() > 5 is safely evaluated

// Multiple conditions
if (obj instanceof String s && !s.isEmpty() && s.startsWith("http")) {
    System.out.println("URL: " + s);
}

// Practical: validate and use in one expression
public void processAge(Object input) {
    if (input instanceof Integer age && age >= 0 && age <= 150) {
        System.out.println("Valid age: " + age);
    } else {
        System.out.println("Invalid age input: " + input);
    }
}

Does NOT Work: Pattern Matching with || (OR)

The || operator is the opposite: if the left side is true, the right side is never evaluated. This creates a problem: if instanceof is on the left and passes, the right side is skipped -- but if the left side fails, the pattern variable was never bound, so the right side cannot use it.

// DOES NOT COMPILE: pattern variable used with ||
if (obj instanceof String s || s.length() > 5) {  // COMPILE ERROR
    System.out.println(s);
}
// Why this fails:
// 1. If obj IS a String, || short-circuits, the right side never runs
//    -- but s IS bound, so the body could use it
// 2. If obj is NOT a String, s was NEVER bound, but the right side
//    tries to use s.length() -- this is unsafe
// The compiler rejects it because s is not definitely bound on all paths

// ALSO DOES NOT COMPILE: two patterns with ||
if (obj instanceof String s || obj instanceof Integer s) {  // ERROR
    // Even if both bind 's', the types differ (String vs Integer)
}

Rules Summary

Expression Compiles? Reason
x instanceof String s && s.length() > 0 Yes && guarantees s is bound before right side runs
x instanceof String s && x instanceof Integer i Yes (but always false) Syntactically valid, but no object is both String and Integer
x instanceof String s || s.length() > 0 No s might not be bound when right side runs
x instanceof String s || x instanceof Integer i Yes, but neither variable is usable in the body Neither s nor i is definitely bound
!(x instanceof String s) || s.isEmpty() Yes If left is false, x IS a String, so s is bound for the right side

The last row is worth studying. The expression !(x instanceof String s) || s.isEmpty() works because: if the negation is false (meaning x instanceof String s is true), then s is bound and the right side s.isEmpty() runs safely. If the negation is true, the || short-circuits and s.isEmpty() is never reached.

5. Pattern Matching in Complex Conditions

In real-world code, you often need to combine type checks with additional validation. Pattern matching integrates cleanly into complex conditional logic.

Nested Conditions

// Nested type checking
public void processNestedData(Object data) {
    if (data instanceof Map map) {
        Object value = map.get("payload");
        if (value instanceof List list) {
            for (Object element : list) {
                if (element instanceof String s) {
                    System.out.println("Found string in payload: " + s);
                }
            }
        }
    }
}

// Real-world example: processing API responses
public void handleApiResponse(Object response) {
    if (response instanceof Map body) {
        Object status = body.get("status");
        if (status instanceof Integer code && code == 200) {
            Object data = body.get("data");
            if (data instanceof List items) {
                System.out.println("Success: received " + items.size() + " items");
            }
        } else if (status instanceof Integer code && code >= 400) {
            Object error = body.get("error");
            if (error instanceof String message) {
                System.out.println("Error " + code + ": " + message);
            }
        }
    }
}

Guard Conditions

A guard condition is an additional boolean check that runs after the type check passes. With &&, you can express this in a single line:

// Guard conditions with pattern matching
public String categorizeInput(Object input) {
    if (input instanceof String s && s.isBlank()) {
        return "Empty string";
    } else if (input instanceof String s && s.length() <= 10) {
        return "Short string: " + s;
    } else if (input instanceof String s) {
        return "Long string: " + s.substring(0, 10) + "...";
    } else if (input instanceof Integer i && i < 0) {
        return "Negative number: " + i;
    } else if (input instanceof Integer i && i == 0) {
        return "Zero";
    } else if (input instanceof Integer i) {
        return "Positive number: " + i;
    }
    return "Unknown: " + input;
}

// Combining with method calls as guards
public boolean isValidEmail(Object input) {
    return input instanceof String email
        && email.contains("@")
        && email.contains(".")
        && email.indexOf("@") < email.lastIndexOf(".")
        && !email.startsWith("@")
        && !email.endsWith(".");
}

// Multiple type checks in sequence with guards
public double calculateDiscount(Object customer, Object orderTotal) {
    if (customer instanceof String type && orderTotal instanceof Double total) {
        if (type.equals("VIP") && total > 100.0) {
            return total * 0.20;  // 20% discount
        } else if (type.equals("REGULAR") && total > 200.0) {
            return total * 0.10;  // 10% discount
        }
    }
    return 0.0;
}

Ternary Operator with Pattern Matching

Pattern matching works in any boolean expression, including the ternary operator:

// Pattern matching in ternary -- concise one-liners
Object obj = "Hello World";

String result = obj instanceof String s ? s.toUpperCase() : "not a string";
System.out.println(result);  // HELLO WORLD

int length = obj instanceof String s ? s.length() : -1;
System.out.println(length);  // 11

// Practical: null-safe type conversion
public static String safeToString(Object obj) {
    return obj instanceof String s ? s
         : obj != null ? obj.toString()
         : "null";
}

6. Pattern Variables and Reassignment

Pattern variables have specific rules about mutability and shadowing that you need to understand.

Pattern Variables Are Not Final (But Should Be Treated As Such)

Unlike what many developers assume, pattern variables are not implicitly final. You can reassign them. However, doing so is almost always a bad idea because it defeats the purpose of the feature and makes code confusing.

// Pattern variables CAN be reassigned (but you shouldn't)
if (obj instanceof String s) {
    System.out.println(s);  // original value
    s = "modified";         // legal, but confusing -- don't do this
    System.out.println(s);  // "modified"
}

// Why you shouldn't: it breaks the mental model
// When you read 'obj instanceof String s', you expect s == (String) obj
// Reassigning s breaks that expectation

Shadowing Rules

Pattern variables can shadow fields and local variables, but the rules depend on the scope:

public class ShadowingExample {
    String name = "field";  // instance field

    public void demonstrateShadowing(Object obj) {
        String name = "local";  // local variable shadows field

        if (obj instanceof String name) {
            // COMPILE ERROR in Java 17!
            // Pattern variable cannot shadow a local variable
            // in the enclosing scope
        }
    }

    public void fieldShadowing(Object obj) {
        // Pattern variable CAN shadow an instance field
        if (obj instanceof String name) {
            System.out.println(name);  // pattern variable, not the field
        }
        System.out.println(name);  // the field "field"
    }
}

// Avoid shadowing altogether -- use descriptive names
public void processItem(Object item) {
    if (item instanceof String text) {
        // 'text' is clear and doesn't shadow anything
        System.out.println(text.toUpperCase());
    }
}

Best Practice: Treat Pattern Variables as Read-Only

Rule Recommendation
Reassignment Technically allowed, but avoid it. The variable should represent the casted original object.
Shadowing local variables Not allowed -- compiler error. Choose a different name.
Shadowing fields Allowed but discouraged. Use descriptive names to avoid confusion.
Naming convention Use short, descriptive names: s for String, i for Integer, or full names like message, count

7. Using with equals() and compareTo()

Pattern matching shines when implementing equals() and compareTo() methods, which are two of the most common places where instanceof + cast appears in Java code.

equals() Method

// OLD WAY: equals() with manual instanceof + cast
public class Employee {
    private String name;
    private int id;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof Employee)) return false;
        Employee other = (Employee) obj;  // redundant cast
        return this.id == other.id
            && Objects.equals(this.name, other.name);
    }
}

// NEW WAY: equals() with pattern matching
public class Employee {
    private String name;
    private int id;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof Employee other)) return false;
        // 'other' is in scope here because of the negated guard clause
        return this.id == other.id
            && Objects.equals(this.name, other.name);
    }
}

// Even more concise: single expression
@Override
public boolean equals(Object obj) {
    return this == obj
        || (obj instanceof Employee other
            && this.id == other.id
            && Objects.equals(this.name, other.name));
}

compareTo() Method

// OLD WAY: compareTo with manual cast
public class Product implements Comparable {
    private String name;
    private double price;

    @Override
    public int compareTo(Object obj) {
        if (!(obj instanceof Product)) {
            throw new ClassCastException("Expected Product");
        }
        Product other = (Product) obj;
        int priceCompare = Double.compare(this.price, other.price);
        return priceCompare != 0 ? priceCompare : this.name.compareTo(other.name);
    }
}

// NEW WAY: compareTo with pattern matching
public class Product implements Comparable {
    private String name;
    private double price;

    @Override
    public int compareTo(Object obj) {
        if (!(obj instanceof Product other)) {
            throw new ClassCastException("Expected Product");
        }
        // 'other' is in scope -- guard clause pattern
        int priceCompare = Double.compare(this.price, other.price);
        return priceCompare != 0 ? priceCompare : this.name.compareTo(other.name);
    }
}

Practical: Value Objects with Pattern Matching

// A complete value object using pattern matching in equals()
public class Money {
    private final double amount;
    private final String currency;

    public Money(double amount, String currency) {
        this.amount = amount;
        this.currency = currency.toUpperCase();
    }

    @Override
    public boolean equals(Object obj) {
        return this == obj
            || (obj instanceof Money other
                && Double.compare(this.amount, other.amount) == 0
                && this.currency.equals(other.currency));
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }

    @Override
    public String toString() {
        return currency + " " + String.format("%.2f", amount);
    }
}

// Usage
Money a = new Money(29.99, "USD");
Money b = new Money(29.99, "usd");
System.out.println(a.equals(b));  // true

8. Before vs After Comparison

The best way to appreciate pattern matching is to see real code transformations. Here are several before-and-after examples from common patterns in Java codebases.

Example 1: Processing Events

// BEFORE: Event processing with manual casts
public void handleEvent(Event event) {
    if (event instanceof ClickEvent) {
        ClickEvent click = (ClickEvent) event;
        System.out.println("Click at " + click.getX() + ", " + click.getY());
    } else if (event instanceof KeyEvent) {
        KeyEvent key = (KeyEvent) event;
        System.out.println("Key pressed: " + key.getKeyCode());
    } else if (event instanceof ScrollEvent) {
        ScrollEvent scroll = (ScrollEvent) event;
        System.out.println("Scroll delta: " + scroll.getDelta());
    }
}

// AFTER: Clean and concise
public void handleEvent(Event event) {
    if (event instanceof ClickEvent click) {
        System.out.println("Click at " + click.getX() + ", " + click.getY());
    } else if (event instanceof KeyEvent key) {
        System.out.println("Key pressed: " + key.getKeyCode());
    } else if (event instanceof ScrollEvent scroll) {
        System.out.println("Scroll delta: " + scroll.getDelta());
    }
}

Example 2: Exception Handling

// BEFORE: Extracting details from exception causes
public String getErrorMessage(Throwable t) {
    if (t instanceof SQLException) {
        SQLException sqlEx = (SQLException) t;
        return "SQL Error " + sqlEx.getErrorCode() + ": " + sqlEx.getMessage();
    } else if (t instanceof FileNotFoundException) {
        FileNotFoundException fnf = (FileNotFoundException) t;
        return "File not found: " + fnf.getMessage();
    } else if (t instanceof NumberFormatException) {
        NumberFormatException nfe = (NumberFormatException) t;
        return "Invalid number: " + nfe.getMessage();
    }
    return "Error: " + t.getMessage();
}

// AFTER: Half the lines, same logic
public String getErrorMessage(Throwable t) {
    if (t instanceof SQLException sqlEx) {
        return "SQL Error " + sqlEx.getErrorCode() + ": " + sqlEx.getMessage();
    } else if (t instanceof FileNotFoundException fnf) {
        return "File not found: " + fnf.getMessage();
    } else if (t instanceof NumberFormatException nfe) {
        return "Invalid number: " + nfe.getMessage();
    }
    return "Error: " + t.getMessage();
}

Example 3: Factory Method with Validation

// BEFORE: Creating objects from untyped input
public Notification createNotification(Object config) {
    if (config instanceof Map) {
        Map map = (Map) config;
        String type = (String) map.get("type");
        String message = (String) map.get("message");
        if (type.equals("email")) {
            return new EmailNotification(message, (String) map.get("to"));
        } else if (type.equals("sms")) {
            return new SmsNotification(message, (String) map.get("phone"));
        }
    } else if (config instanceof String) {
        String simple = (String) config;
        return new LogNotification(simple);
    }
    throw new IllegalArgumentException("Invalid config");
}

// AFTER: Cleaner with pattern matching
public Notification createNotification(Object config) {
    if (config instanceof Map map) {
        String type = (String) map.get("type");
        String message = (String) map.get("message");
        if ("email".equals(type)) {
            return new EmailNotification(message, (String) map.get("to"));
        } else if ("sms".equals(type)) {
            return new SmsNotification(message, (String) map.get("phone"));
        }
    } else if (config instanceof String simple) {
        return new LogNotification(simple);
    }
    throw new IllegalArgumentException("Invalid config");
}

Example 4: Implementing toString() for Wrapper Types

// BEFORE: Formatting different types for display
public String formatValue(Object value) {
    if (value == null) {
        return "null";
    } else if (value instanceof String) {
        String s = (String) value;
        return "\"" + s + "\"";
    } else if (value instanceof Double) {
        Double d = (Double) value;
        return String.format("%.2f", d);
    } else if (value instanceof LocalDate) {
        LocalDate date = (LocalDate) value;
        return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
    } else if (value instanceof Collection) {
        Collection coll = (Collection) value;
        return "[" + coll.size() + " items]";
    }
    return value.toString();
}

// AFTER: Every line saved is a line that can't have a bug
public String formatValue(Object value) {
    if (value == null) {
        return "null";
    } else if (value instanceof String s) {
        return "\"" + s + "\"";
    } else if (value instanceof Double d) {
        return String.format("%.2f", d);
    } else if (value instanceof LocalDate date) {
        return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
    } else if (value instanceof Collection coll) {
        return "[" + coll.size() + " items]";
    }
    return value.toString();
}

Example 5: equals() in a Class Hierarchy

// BEFORE: equals() with getClass() check and manual cast
public class Account {
    private final String id;
    private final String owner;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Account account = (Account) obj;
        return Objects.equals(id, account.id)
            && Objects.equals(owner, account.owner);
    }
}

// AFTER: Concise and clear
public class Account {
    private final String id;
    private final String owner;

    @Override
    public boolean equals(Object obj) {
        return this == obj
            || (obj instanceof Account account
                && Objects.equals(id, account.id)
                && Objects.equals(owner, account.owner));
    }
}
// Note: using instanceof instead of getClass() means subclass instances
// can be equal to parent instances. Choose based on your equals() contract.

9. Common Patterns

Pattern matching for instanceof is not just syntactic sugar. It enables several design patterns that were previously verbose or impractical in Java.

Pattern 1: Type-Based Dispatch

This is the most common use case -- routing logic based on an object's runtime type. Before pattern matching, this required either the visitor pattern or chains of instanceof-plus-cast.

// Type-based dispatch: processing different message types
public class MessageProcessor {

    public void process(Object message) {
        if (message instanceof TextMessage text) {
            handleText(text);
        } else if (message instanceof ImageMessage image) {
            handleImage(image);
        } else if (message instanceof VideoMessage video) {
            handleVideo(video);
        } else if (message instanceof FileMessage file && file.getSize() < 10_000_000) {
            handleSmallFile(file);
        } else if (message instanceof FileMessage file) {
            handleLargeFile(file);
        } else {
            throw new UnsupportedOperationException(
                "Unknown message type: " + message.getClass().getSimpleName()
            );
        }
    }

    private void handleText(TextMessage msg) {
        System.out.println("Text: " + msg.getContent());
    }

    private void handleImage(ImageMessage msg) {
        System.out.printf("Image: %dx%d, format=%s%n",
            msg.getWidth(), msg.getHeight(), msg.getFormat());
    }

    private void handleVideo(VideoMessage msg) {
        System.out.printf("Video: %s, duration=%ds%n",
            msg.getTitle(), msg.getDuration());
    }

    private void handleSmallFile(FileMessage msg) {
        System.out.println("Small file, processing inline: " + msg.getName());
    }

    private void handleLargeFile(FileMessage msg) {
        System.out.println("Large file, queueing for async: " + msg.getName());
    }
}

Pattern 2: Visitor Pattern Replacement

The visitor pattern is a classic solution for adding operations to a class hierarchy without modifying the classes. But it is verbose, requires double dispatch, and is hard to understand. Pattern matching provides a simpler alternative for many use cases.

// OLD WAY: Full visitor pattern (verbose)
interface ShapeVisitor {
    double visit(Circle c);
    double visit(Rectangle r);
    double visit(Triangle t);
}

class AreaCalculator implements ShapeVisitor {
    public double visit(Circle c) { return Math.PI * c.radius() * c.radius(); }
    public double visit(Rectangle r) { return r.width() * r.height(); }
    public double visit(Triangle t) { return 0.5 * t.base() * t.height(); }
}

// Each shape class needs an accept() method:
// public double accept(ShapeVisitor v) { return v.visit(this); }

// Usage:
// double area = shape.accept(new AreaCalculator());


// NEW WAY: Pattern matching -- no visitor interface needed
public static double calculateArea(Shape shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.radius() * c.radius();
    } else if (shape instanceof Rectangle r) {
        return r.width() * r.height();
    } else if (shape instanceof Triangle t) {
        return 0.5 * t.base() * t.height();
    }
    throw new IllegalArgumentException("Unknown shape: " + shape);
}

// Usage:
// double area = calculateArea(shape);
// No visitor interface, no accept() methods, no double dispatch

Pattern 3: Polymorphic Utility Methods

Sometimes you need to operate on objects from third-party libraries where you cannot add methods. Pattern matching makes this straightforward:

// Utility: safely extract a string representation from various types
public final class JsonUtils {

    public static String toJsonValue(Object value) {
        if (value == null) {
            return "null";
        } else if (value instanceof String s) {
            return "\"" + escapeJson(s) + "\"";
        } else if (value instanceof Number n) {
            return n.toString();
        } else if (value instanceof Boolean b) {
            return b.toString();
        } else if (value instanceof Collection coll) {
            return coll.stream()
                .map(JsonUtils::toJsonValue)
                .collect(Collectors.joining(", ", "[", "]"));
        } else if (value instanceof Map map) {
            return map.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\": " + toJsonValue(e.getValue()))
                .collect(Collectors.joining(", ", "{", "}"));
        }
        return "\"" + value.toString() + "\"";
    }

    private static String escapeJson(String s) {
        return s.replace("\\", "\\\\")
                .replace("\"", "\\\"")
                .replace("\n", "\\n")
                .replace("\t", "\\t");
    }
}

// Usage
System.out.println(JsonUtils.toJsonValue("hello"));         // "hello"
System.out.println(JsonUtils.toJsonValue(42));               // 42
System.out.println(JsonUtils.toJsonValue(List.of(1, 2, 3))); // [1, 2, 3]

Pattern 4: Adapter / Converter Pattern

// Converting between different date/time representations
public static LocalDateTime toLocalDateTime(Object input) {
    if (input instanceof LocalDateTime ldt) {
        return ldt;
    } else if (input instanceof LocalDate ld) {
        return ld.atStartOfDay();
    } else if (input instanceof Instant instant) {
        return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
    } else if (input instanceof Date date) {
        return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
    } else if (input instanceof Long epochMillis) {
        return LocalDateTime.ofInstant(
            Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()
        );
    } else if (input instanceof String s) {
        return LocalDateTime.parse(s, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
    }
    throw new IllegalArgumentException(
        "Cannot convert " + input.getClass().getSimpleName() + " to LocalDateTime"
    );
}

// Usage
LocalDateTime dt1 = toLocalDateTime(LocalDate.of(2024, 1, 15));
LocalDateTime dt2 = toLocalDateTime(Instant.now());
LocalDateTime dt3 = toLocalDateTime("2024-01-15T10:30:00");
LocalDateTime dt4 = toLocalDateTime(1705312200000L);

10. Best Practices

Pattern matching for instanceof is a tool, not a silver bullet. Here are guidelines for using it effectively.

When to Use Pattern Matching

Scenario Use Pattern Matching? Reasoning
Processing untyped data (JSON, configs) Yes You are dealing with Object types and need to dispatch by runtime type
Implementing equals() Yes Perfect fit -- eliminates the standard instanceof + cast boilerplate
Exception handling with specific types Yes Cleaner extraction of exception-specific information
Third-party types you cannot modify Yes You cannot add polymorphic methods, so type dispatch is your only option
Your own class hierarchy Maybe Consider polymorphism first. Pattern matching is a fallback, not a default.
Replacing polymorphic methods No If you own the types and can add methods, polymorphism is almost always better
Deeply nested type checks Cautiously If you need 3+ levels of nesting, rethink the design

Pattern Matching vs Polymorphism

The question every developer asks: "Should I use pattern matching or polymorphism?" The answer depends on where the operation is defined and who owns the types.

// POLYMORPHISM: when you own the types and the operation is core behavior
// Each shape knows how to calculate its own area
interface Shape {
    double area();
}

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; }
}

// Adding a new shape? Just implement the interface -- no other code changes.
// This is the Open/Closed Principle in action.


// PATTERN MATCHING: when you DON'T own the types or the operation is external
// Exporting shapes to SVG -- this is NOT core shape behavior
public static String toSvg(Shape shape) {
    if (shape instanceof Circle c) {
        return String.format("", c.radius());
    } else if (shape instanceof Rectangle r) {
        return String.format("",
            r.width(), r.height());
    }
    throw new IllegalArgumentException("Unsupported shape");
}
// The SVG export is an external concern -- it doesn't belong in the Shape interface.
// Pattern matching is the right tool here.

Readability Guidelines

  • Use short variable names for short scopes: if (obj instanceof String s) is fine when the block is 1-3 lines. For longer blocks, use descriptive names like message or errorText.
  • Prefer guard clauses over nesting: Use the negated if (!(obj instanceof Type t)) return; pattern to keep code flat.
  • Do not chain more than 5-6 instanceof checks: If you have more, it is a code smell. Consider a Map-based dispatch, a strategy pattern, or refactoring the type hierarchy.
  • Keep guard conditions simple: if (obj instanceof String s && s.length() > 5) is readable. if (obj instanceof String s && s.length() > 5 && !s.isBlank() && s.matches("^[a-z]+$")) should be extracted into a method.
  • Combine with sealed classes for exhaustive checking: In Java 21+, sealed classes + pattern matching in switch gives you compile-time exhaustiveness guarantees.

Migration Strategy

When migrating an existing codebase to use pattern matching:

  1. Start with equals() methods -- they are the most mechanical and safest to convert.
  2. Convert utility/helper methods next -- methods that process Object parameters.
  3. Tackle type-dispatch chains last -- these may need broader refactoring if the design should use polymorphism instead.
  4. Do not force it -- if the old code is clear and correct, leaving it alone is a valid choice. Pattern matching is about improving readability, not rewriting everything.

Quick Reference

Feature Detail
Syntax if (obj instanceof Type variable) { ... }
JEP JEP 394 -- finalized in Java 16, permanent in Java 17 LTS
Null safety instanceof returns false for null, so pattern variables are never null
Scope Flow-scoped: available where the compiler can prove the match succeeded
Works with && Yes -- obj instanceof String s && s.length() > 5
Works with || Limited -- the pattern variable is not in scope on the right side of ||
Reassignment Allowed but strongly discouraged
Works in switch Preview in Java 17, finalized in Java 21
Works with generics Yes -- obj instanceof List list (but not List due to erasure)
IDE support IntelliJ, Eclipse, and VS Code all offer automated refactoring to convert old patterns
March 1, 2026

Java 17 Switch Expressions

1. Introduction

The traditional switch statement has been part of Java since version 1.0, and for decades it has been one of the most common sources of subtle bugs. The problem is not the concept — branching on a value is fundamental to programming — but the implementation. The classic switch inherited its design from C, and with it came fall-through semantics: if you forget a break statement, execution silently falls through to the next case. This single design choice has caused more production bugs than anyone wants to count.

Consider this classic example of a fall-through bug:

// Classic fall-through bug -- spot the problem
public String getDayType(String day) {
    String type;
    switch (day) {
        case "MONDAY":
        case "TUESDAY":
        case "WEDNESDAY":
        case "THURSDAY":
        case "FRIDAY":
            type = "Weekday";
            // Missing break! Falls through to "Weekend"
        case "SATURDAY":
        case "SUNDAY":
            type = "Weekend";
            break;
        default:
            type = "Unknown";
    }
    return type; // Always returns "Weekend" for weekdays!
}

That missing break means every weekday falls through to the “Weekend” case. The code compiles without warnings. It runs without exceptions. It simply returns the wrong answer. These bugs are notoriously hard to spot in code review and even harder to catch in testing if your test cases happen to start with “SATURDAY.”

Beyond fall-through, the traditional switch has other pain points:

  • Verbosity — Every case needs a break statement, bloating the code
  • Not an expression — You cannot assign the result of a switch to a variable directly, so you must declare the variable before the switch and assign it inside each case
  • No exhaustiveness checking — The compiler does not warn you if you forgot a case (except for enums in some IDEs)
  • Scope leaks — Variables declared in one case are visible in subsequent cases without curly braces

Switch expressions fix all of this. They were introduced as a preview feature in Java 12 (JEP 325), refined in Java 13 (JEP 354), and became a permanent feature in Java 14 (JEP 361). In Java 17 — the current LTS release — switch expressions are stable, battle-tested, and should be your default choice over the traditional switch statement.

What switch expressions bring to the table:

Feature Traditional Switch Switch Expression
Fall-through Default behavior (bug-prone) No fall-through with arrow syntax
Returns a value No — it is a statement Yes — it is an expression
Exhaustiveness Not enforced Compiler-enforced
Multiple labels Stacked cases with fall-through Comma-separated: case A, B, C
Verbosity High (break on every case) Low (arrow syntax is concise)

2. Arrow Labels

The first major change is the introduction of the arrow syntax (->) for case labels. Instead of case X: (colon form), you write case X -> (arrow form). The arrow form eliminates fall-through entirely. When execution enters an arrow case, it runs only that case’s code and then exits the switch. No break needed. No fall-through possible.

public class ArrowLabelsDemo {
    public static void main(String[] args) {

        String day = "WEDNESDAY";

        // Traditional colon syntax -- fall-through is possible
        System.out.println("=== Traditional ===");
        switch (day) {
            case "MONDAY":
                System.out.println("Start of work week");
                break;
            case "WEDNESDAY":
                System.out.println("Midweek");
                break;
            case "FRIDAY":
                System.out.println("Almost weekend!");
                break;
            default:
                System.out.println("Regular day");
                break;
        }

        // Arrow syntax -- no fall-through, no break needed
        System.out.println("=== Arrow ===");
        switch (day) {
            case "MONDAY"    -> System.out.println("Start of work week");
            case "WEDNESDAY" -> System.out.println("Midweek");
            case "FRIDAY"    -> System.out.println("Almost weekend!");
            default          -> System.out.println("Regular day");
        }

        // Arrow with block bodies -- use curly braces for multiple statements
        switch (day) {
            case "MONDAY" -> {
                System.out.println("Monday");
                System.out.println("Time to plan the week");
            }
            case "FRIDAY" -> {
                System.out.println("Friday");
                System.out.println("Time to wrap up");
            }
            default -> System.out.println("Regular day: " + day);
        }
    }
}

Key rules for arrow labels:

  • No fall-through — each case runs independently
  • No break needed (and you should not use it)
  • Single expression or statement to the right of the arrow
  • For multiple statements, use a block with curly braces: case X -> { ... }
  • You cannot mix arrow and colon forms in the same switch — pick one
public class NoMixingDemo {
    public static void main(String[] args) {
        String day = "MONDAY";

        // COMPILE ERROR: cannot mix arrow and colon labels
        // switch (day) {
        //     case "MONDAY" -> System.out.println("Monday");
        //     case "TUESDAY":
        //         System.out.println("Tuesday");
        //         break;
        // }

        // Pick one style and stick with it in each switch
    }
}

3. Switch as Expression

This is the game-changer. A switch expression produces a value, just like a ternary operator or a method call. You can assign the result of a switch directly to a variable, return it from a method, or pass it as an argument. No more declaring a variable before the switch and assigning it in each case.

public class SwitchExpressionDemo {
    public static void main(String[] args) {

        String day = "WEDNESDAY";

        // OLD WAY: declare variable, assign in each case
        String dayType;
        switch (day) {
            case "MONDAY":
            case "TUESDAY":
            case "WEDNESDAY":
            case "THURSDAY":
            case "FRIDAY":
                dayType = "Weekday";
                break;
            case "SATURDAY":
            case "SUNDAY":
                dayType = "Weekend";
                break;
            default:
                dayType = "Unknown";
                break;
        }

        // NEW WAY: switch expression assigns directly
        String dayTypeNew = switch (day) {
            case "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY" -> "Weekday";
            case "SATURDAY", "SUNDAY" -> "Weekend";
            default -> "Unknown";
        };  // Note the semicolon -- the switch expression is part of an assignment statement

        System.out.println(dayType);    // Weekday
        System.out.println(dayTypeNew); // Weekday

        // Using switch expression in a return statement
        System.out.println(categorize(85));
        System.out.println(categorize(42));

        // Using switch expression as a method argument
        System.out.println("Grade: " + switch (95) {
            case 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100 -> "A";
            default -> "Other";
        });

        // Using switch expression in a calculation
        int month = 6;
        int daysInMonth = switch (month) {
            case 1, 3, 5, 7, 8, 10, 12 -> 31;
            case 4, 6, 9, 11 -> 30;
            case 2 -> 28; // simplified, ignoring leap years
            default -> throw new IllegalArgumentException("Invalid month: " + month);
        };
        System.out.println("Days in month " + month + ": " + daysInMonth);
    }

    static String categorize(int score) {
        return switch (score / 10) {
            case 10, 9 -> "Excellent";
            case 8     -> "Good";
            case 7     -> "Average";
            case 6     -> "Below Average";
            default    -> "Needs Improvement";
        };
    }
}

Critical detail: When a switch is used as an expression (i.e., its result is assigned, returned, or used), it must be exhaustive. Every possible input value must be handled. If the compiler cannot verify exhaustiveness, you must include a default case. More on this in section 6.

Also note the semicolon after the closing brace when a switch expression is part of a statement. This is easy to forget:

// The semicolon terminates the assignment statement, not the switch
int result = switch (x) {
    case 1 -> 10;
    case 2 -> 20;
    default -> 0;
};  // <-- This semicolon is required!

// Same as writing:
// int result = someMethodThatReturnsInt();
//                                        ^ semicolon terminates the statement

4. The yield Keyword

When an arrow case needs to compute a value through multiple statements, you use the yield keyword to return the value from the block. Think of yield as the switch-expression equivalent of return -- it specifies the value that the case produces.

When do you need yield? Only when you have a block body (curly braces) in a switch expression. Single-expression arrow cases produce their value directly. Statement switches (not used as expressions) do not need yield at all.

public class YieldDemo {
    public static void main(String[] args) {

        int month = 3;
        int year = 2024;

        // Simple arrow cases -- no yield needed, the expression IS the value
        int daysSimple = switch (month) {
            case 1, 3, 5, 7, 8, 10, 12 -> 31;
            case 4, 6, 9, 11 -> 30;
            case 2 -> 28;
            default -> throw new IllegalArgumentException("Invalid month");
        };

        // Block body -- yield IS needed to produce the value
        int daysComplex = switch (month) {
            case 1, 3, 5, 7, 8, 10, 12 -> {
                System.out.println("31-day month");
                yield 31;
            }
            case 4, 6, 9, 11 -> {
                System.out.println("30-day month");
                yield 30;
            }
            case 2 -> {
                boolean isLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
                System.out.println(isLeap ? "Leap year February" : "Regular February");
                yield isLeap ? 29 : 28;
            }
            default -> throw new IllegalArgumentException("Invalid month: " + month);
        };

        System.out.println("Days in month " + month + ": " + daysComplex);

        // yield also works with colon-style cases in switch expressions
        String season = switch (month) {
            case 12, 1, 2:
                yield "Winter";
            case 3, 4, 5:
                yield "Spring";
            case 6, 7, 8:
                yield "Summer";
            case 9, 10, 11:
                yield "Fall";
            default:
                yield "Unknown";
        };
        System.out.println("Season: " + season);
    }
}

yield vs return:

Keyword Context Effect
return Inside a method Exits the method and returns a value to the caller
yield Inside a switch expression block Produces the value for that case without exiting the method
break Inside a traditional switch statement Exits the switch (no value produced)

Important: yield is a context-sensitive keyword, not a reserved word. You can still have variables, methods, or classes named yield (though you probably should not). It only has special meaning inside a switch expression block.

public class YieldNotReserved {
    public static void main(String[] args) {

        // "yield" is not a reserved word -- this compiles fine
        int yield = 42;
        System.out.println(yield); // 42

        // But inside a switch expression, "yield" has special meaning
        int result = switch (yield) {
            case 42 -> {
                int computed = yield * 2;  // "yield" here is the variable
                yield computed;             // "yield" here is the keyword
            }
            default -> 0;
        };
        System.out.println(result); // 84
    }
}

5. Multiple Case Labels

In the traditional switch, handling multiple values with the same logic required stacking case labels using fall-through:

public class MultipleCaseLabels {
    public static void main(String[] args) {

        int statusCode = 404;

        // OLD WAY: stacked cases relying on fall-through
        String categoryOld;
        switch (statusCode) {
            case 200:
            case 201:
            case 202:
            case 204:
                categoryOld = "Success";
                break;
            case 301:
            case 302:
            case 307:
            case 308:
                categoryOld = "Redirect";
                break;
            case 400:
            case 401:
            case 403:
            case 404:
            case 422:
                categoryOld = "Client Error";
                break;
            case 500:
            case 502:
            case 503:
            case 504:
                categoryOld = "Server Error";
                break;
            default:
                categoryOld = "Unknown";
                break;
        }

        // NEW WAY: comma-separated case labels -- clean and explicit
        String categoryNew = switch (statusCode) {
            case 200, 201, 202, 204     -> "Success";
            case 301, 302, 307, 308     -> "Redirect";
            case 400, 401, 403, 404, 422 -> "Client Error";
            case 500, 502, 503, 504     -> "Server Error";
            default                      -> "Unknown";
        };

        System.out.println(categoryOld); // Client Error
        System.out.println(categoryNew); // Client Error

        // Multiple labels work with strings too
        String fruit = "apple";
        String color = switch (fruit) {
            case "apple", "cherry", "strawberry"   -> "Red";
            case "banana", "lemon", "pineapple"    -> "Yellow";
            case "lime", "kiwi", "avocado"         -> "Green";
            case "blueberry", "grape", "plum"      -> "Purple";
            case "orange", "tangerine", "mango"    -> "Orange";
            default                                 -> "Unknown color";
        };
        System.out.println(fruit + " is " + color); // apple is Red
    }
}

The comma-separated syntax communicates intent far better than stacked fall-through cases. When you read case 200, 201, 202, 204 ->, you immediately understand that all these values lead to the same result. With the traditional syntax, you have to mentally verify that there is no code between the stacked cases -- any statement would change the behavior.

6. Exhaustiveness

When switch is used as an expression (its value is assigned, returned, or used), the compiler requires it to be exhaustive. This means every possible value of the selector must be handled by some case. If the compiler cannot prove exhaustiveness, the code does not compile.

This is a major safety improvement. The traditional switch statement was happy to silently do nothing if no case matched. A switch expression forces you to handle every possibility or explicitly acknowledge unknown values with default.

public class ExhaustivenessDemo {
    public static void main(String[] args) {

        int value = 5;

        // COMPILE ERROR: switch expression must be exhaustive
        // String label = switch (value) {
        //     case 1 -> "One";
        //     case 2 -> "Two";
        //     case 3 -> "Three";
        //     // Missing default! int has ~4 billion possible values
        // };

        // CORRECT: add default to handle remaining values
        String label = switch (value) {
            case 1 -> "One";
            case 2 -> "Two";
            case 3 -> "Three";
            default -> "Other: " + value;
        };
        System.out.println(label); // Other: 5

        // Switch STATEMENTS (not expressions) are NOT required to be exhaustive
        // This compiles fine, even though it does not handle all ints:
        switch (value) {
            case 1 -> System.out.println("One");
            case 2 -> System.out.println("Two");
            // no default -- and that is fine for statements
        }

        // You can use default to throw an exception for unexpected values
        char grade = 'B';
        double gradePoints = switch (grade) {
            case 'A' -> 4.0;
            case 'B' -> 3.0;
            case 'C' -> 2.0;
            case 'D' -> 1.0;
            case 'F' -> 0.0;
            default -> throw new IllegalArgumentException("Invalid grade: " + grade);
        };
        System.out.println("Grade points: " + gradePoints);
    }
}

Exhaustiveness rules:

Selector Type Exhaustive Without Default? Notes
int, short, byte, char No (too many values) Always need default
String No (infinite values) Always need default
enum Yes, if all constants covered No default needed (but recommended)
Sealed class (Java 17+) Yes, if all permitted subtypes covered Works with pattern matching (Java 21)

7. Switch with Enums

Enums and switch expressions are a natural fit. Since an enum has a fixed set of constants, the compiler can verify that your switch handles all of them. If you cover every enum constant, you do not need a default case. And if someone later adds a new constant to the enum, the compiler will flag every switch expression that does not handle it. This is exactly the kind of compile-time safety that prevents production bugs.

public class SwitchWithEnums {

    enum Season { SPRING, SUMMER, FALL, WINTER }

    enum HttpMethod { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }

    enum Priority { LOW, MEDIUM, HIGH, CRITICAL }

    public static void main(String[] args) {

        // Exhaustive without default -- all enum constants covered
        Season season = Season.SUMMER;
        String activity = switch (season) {
            case SPRING -> "Gardening";
            case SUMMER -> "Swimming";
            case FALL   -> "Hiking";
            case WINTER -> "Skiing";
            // No default needed! All 4 constants are covered.
        };
        System.out.println(season + ": " + activity);

        // If you add a new Season constant and forget to update the switch,
        // the compiler will give you an error:
        // "the switch expression does not cover all possible input values"

        // HTTP method handling
        HttpMethod method = HttpMethod.POST;
        String action = switch (method) {
            case GET     -> "Retrieving resource";
            case POST    -> "Creating resource";
            case PUT     -> "Replacing resource";
            case PATCH   -> "Updating resource";
            case DELETE  -> "Deleting resource";
            case HEAD    -> "Checking resource headers";
            case OPTIONS -> "Listing available methods";
        };
        System.out.println(method + ": " + action);

        // Enum with yield for complex logic
        Priority priority = Priority.CRITICAL;
        int responseTimeMinutes = switch (priority) {
            case LOW -> 1440;       // 24 hours
            case MEDIUM -> 240;     // 4 hours
            case HIGH -> 60;        // 1 hour
            case CRITICAL -> {
                System.out.println("ALERT: Critical priority detected!");
                System.out.println("Paging on-call engineer...");
                yield 15;           // 15 minutes
            }
        };
        System.out.println("Response time: " + responseTimeMinutes + " minutes");
    }
}

Should You Add default to Enum Switches?

This is a nuanced topic. If you cover all enum constants, the compiler does not require default. However, there is a case for including it defensively:

public class EnumDefaultDebate {

    enum Color { RED, GREEN, BLUE }

    public static void main(String[] args) {

        Color color = Color.RED;

        // Option 1: No default -- compiler ensures all cases covered
        // If someone adds YELLOW to the enum, THIS code won't compile until updated
        String hex1 = switch (color) {
            case RED   -> "#FF0000";
            case GREEN -> "#00FF00";
            case BLUE  -> "#0000FF";
        };

        // Option 2: With default -- handles future enum constants at runtime
        // If someone adds YELLOW, this code still compiles but throws at runtime
        String hex2 = switch (color) {
            case RED   -> "#FF0000";
            case GREEN -> "#00FF00";
            case BLUE  -> "#0000FF";
            default -> throw new IllegalArgumentException("Unhandled color: " + color);
        };

        // RECOMMENDATION: Omit default for enum switch expressions.
        // A compile error (Option 1) is ALWAYS better than a runtime error (Option 2).
        // The compile error forces you to handle the new case immediately.
        // The runtime error only surfaces when that code path actually executes.
    }
}

8. Switch with Strings and Numbers

Switch expressions work with all the types that traditional switch supports: int, byte, short, char, String, and enum types. For these types, a default case is required when used as an expression (since you cannot enumerate all possible Strings or ints).

public class SwitchWithStringsAndNumbers {
    public static void main(String[] args) {

        // === Switch on String ===
        String command = "deploy";
        String action = switch (command.toLowerCase()) {
            case "build"   -> "Compiling source code...";
            case "test"    -> "Running test suite...";
            case "deploy"  -> "Deploying to production...";
            case "rollback" -> "Rolling back last deployment...";
            case "status"  -> "Checking system status...";
            default        -> "Unknown command: " + command;
        };
        System.out.println(action);

        // === Switch on int ===
        int httpStatus = 201;
        String message = switch (httpStatus) {
            case 200 -> "OK";
            case 201 -> "Created";
            case 204 -> "No Content";
            case 301 -> "Moved Permanently";
            case 302 -> "Found (Redirect)";
            case 400 -> "Bad Request";
            case 401 -> "Unauthorized";
            case 403 -> "Forbidden";
            case 404 -> "Not Found";
            case 500 -> "Internal Server Error";
            case 502 -> "Bad Gateway";
            case 503 -> "Service Unavailable";
            default  -> "HTTP " + httpStatus;
        };
        System.out.println(httpStatus + ": " + message);

        // === Switch on char ===
        char operator = '+';
        double result = switch (operator) {
            case '+' -> 10.0 + 5.0;
            case '-' -> 10.0 - 5.0;
            case '*' -> 10.0 * 5.0;
            case '/' -> 10.0 / 5.0;
            case '%' -> 10.0 % 5.0;
            default  -> throw new IllegalArgumentException("Unknown operator: " + operator);
        };
        System.out.println("10 " + operator + " 5 = " + result);

        // === Range-based switching using int division ===
        int score = 87;
        String grade = switch (score / 10) {
            case 10, 9 -> "A";
            case 8     -> "B";
            case 7     -> "C";
            case 6     -> "D";
            default    -> "F";
        };
        System.out.println("Score " + score + " = Grade " + grade);
    }
}

A Note on Pattern Matching (Preview in Java 17)

Java 17 includes pattern matching for switch as a preview feature (JEP 406). This allows switching on types and destructuring objects directly in case labels. While it is not yet a permanent feature in Java 17, it becomes standard in Java 21. Here is a quick preview of what it looks like:

// PREVIEW FEATURE in Java 17 -- requires --enable-preview flag
// Becomes standard in Java 21

// Pattern matching allows switching on types:
// static String describe(Object obj) {
//     return switch (obj) {
//         case Integer i -> "Integer: " + i;
//         case String s  -> "String of length " + s.length();
//         case int[] arr -> "int array of length " + arr.length;
//         case null      -> "null value";
//         default        -> "Other: " + obj.getClass().getName();
//     };
// }

// For Java 17 production code, stick with standard switch expressions
// Pattern matching will be covered in a dedicated Java 21 tutorial

9. Null Handling

Historically, passing null to a switch statement throws a NullPointerException before any case is evaluated. This behavior has not changed in Java 17 for standard switch expressions. The NPE is thrown at the point where the switch evaluates its selector, not inside any case.

public class NullHandlingDemo {
    public static void main(String[] args) {

        // Traditional switch: NPE on null
        String value = null;
        try {
            // This throws NullPointerException BEFORE entering any case
            switch (value) {
                case "A":
                    System.out.println("A");
                    break;
                default:
                    System.out.println("Default");
            }
        } catch (NullPointerException e) {
            System.out.println("NPE from traditional switch: " + e.getMessage());
        }

        // Switch expression: same behavior -- NPE on null
        try {
            String result = switch (value) {
                case "A" -> "Letter A";
                case "B" -> "Letter B";
                default  -> "Other";
            };
        } catch (NullPointerException e) {
            System.out.println("NPE from switch expression: " + e.getMessage());
        }

        // BEST PRACTICE: Guard against null BEFORE the switch
        String safeResult = handleCommand(null);
        System.out.println(safeResult);

        safeResult = handleCommand("start");
        System.out.println(safeResult);
    }

    // Defensive approach: null check before switch
    static String handleCommand(String command) {
        if (command == null) {
            return "Error: command cannot be null";
        }
        return switch (command) {
            case "start"   -> "Starting...";
            case "stop"    -> "Stopping...";
            case "restart" -> "Restarting...";
            default        -> "Unknown command: " + command;
        };
    }

    // Alternative: use Objects.requireNonNull for fail-fast
    static String processInput(String input) {
        java.util.Objects.requireNonNull(input, "Input must not be null");
        return switch (input) {
            case "yes", "y" -> "Confirmed";
            case "no", "n"  -> "Rejected";
            default         -> "Invalid input: " + input;
        };
    }
}

Note on Java 21+: Starting with Java 21, you can handle null directly as a case label in switch: case null -> "null value". In Java 17, this is only available as a preview feature. For production Java 17 code, always guard against null before the switch.

10. Comparison: Traditional vs Arrow vs Expression

Let us put all three switch forms side by side so you can see the progression from the oldest style to the most modern.

Feature Traditional (Colon + Break) Arrow Statement Switch Expression
Syntax case X: case X -> case X -> (used as expression)
Fall-through Yes (default) No No
Returns value No No (statement form) Yes
Requires break Yes No No
Exhaustive No No (statement form) Yes (compiler-enforced)
Multiple labels Stacked fall-through Comma-separated Comma-separated
Block body Colon cases share scope Curly braces: { } Curly braces with yield
Recommended Legacy code only When no value needed Default choice

Here is the same logic written in all three styles:

public class ThreeWayComparison {

    enum Direction { NORTH, SOUTH, EAST, WEST }

    public static void main(String[] args) {
        Direction dir = Direction.EAST;

        // ========================================
        // STYLE 1: Traditional (colon + break)
        // ========================================
        String result1;
        switch (dir) {
            case NORTH:
                result1 = "Moving up";
                break;
            case SOUTH:
                result1 = "Moving down";
                break;
            case EAST:
                result1 = "Moving right";
                break;
            case WEST:
                result1 = "Moving left";
                break;
            default:
                result1 = "Unknown";
                break;
        }
        System.out.println("Traditional: " + result1);

        // ========================================
        // STYLE 2: Arrow statement (no value)
        // ========================================
        switch (dir) {
            case NORTH -> System.out.println("Arrow: Moving up");
            case SOUTH -> System.out.println("Arrow: Moving down");
            case EAST  -> System.out.println("Arrow: Moving right");
            case WEST  -> System.out.println("Arrow: Moving left");
        }

        // ========================================
        // STYLE 3: Switch expression (returns value)
        // ========================================
        String result3 = switch (dir) {
            case NORTH -> "Moving up";
            case SOUTH -> "Moving down";
            case EAST  -> "Moving right";
            case WEST  -> "Moving left";
        };
        System.out.println("Expression: " + result3);
    }
}

The progression is clear: Style 3 is the most concise, the safest (exhaustive, no fall-through), and the most expressive (produces a value). Use it as your default choice.

11. Real-World Examples

Let us look at practical, production-quality examples that demonstrate how switch expressions clean up real application code.

Example 1: Calculator

public class Calculator {

    enum Operation { ADD, SUBTRACT, MULTIPLY, DIVIDE, MODULO, POWER }

    public static double calculate(double a, Operation op, double b) {
        return switch (op) {
            case ADD      -> a + b;
            case SUBTRACT -> a - b;
            case MULTIPLY -> a * b;
            case DIVIDE -> {
                if (b == 0) {
                    throw new ArithmeticException("Cannot divide by zero");
                }
                yield a / b;
            }
            case MODULO -> {
                if (b == 0) {
                    throw new ArithmeticException("Cannot modulo by zero");
                }
                yield a % b;
            }
            case POWER -> Math.pow(a, b);
        };
    }

    public static String formatResult(double a, Operation op, double b) {
        String symbol = switch (op) {
            case ADD      -> "+";
            case SUBTRACT -> "-";
            case MULTIPLY -> "*";
            case DIVIDE   -> "/";
            case MODULO   -> "%";
            case POWER    -> "^";
        };

        double result = calculate(a, op, b);
        return "%.2f %s %.2f = %.2f".formatted(a, symbol, b, result);
    }

    public static void main(String[] args) {
        System.out.println(formatResult(10, Operation.ADD, 5));       // 10.00 + 5.00 = 15.00
        System.out.println(formatResult(10, Operation.SUBTRACT, 3));  // 10.00 - 3.00 = 7.00
        System.out.println(formatResult(10, Operation.MULTIPLY, 4));  // 10.00 * 4.00 = 40.00
        System.out.println(formatResult(10, Operation.DIVIDE, 3));    // 10.00 / 3.00 = 3.33
        System.out.println(formatResult(10, Operation.MODULO, 3));    // 10.00 % 3.00 = 1.00
        System.out.println(formatResult(2, Operation.POWER, 10));     // 2.00 ^ 10.00 = 1024.00

        // Division by zero
        try {
            System.out.println(formatResult(10, Operation.DIVIDE, 0));
        } catch (ArithmeticException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Example 2: HTTP Status Handler

public class HttpStatusHandler {

    record HttpResponse(int statusCode, String body) {}

    enum StatusCategory { INFORMATIONAL, SUCCESS, REDIRECT, CLIENT_ERROR, SERVER_ERROR }

    public static StatusCategory categorize(int code) {
        return switch (code / 100) {
            case 1 -> StatusCategory.INFORMATIONAL;
            case 2 -> StatusCategory.SUCCESS;
            case 3 -> StatusCategory.REDIRECT;
            case 4 -> StatusCategory.CLIENT_ERROR;
            case 5 -> StatusCategory.SERVER_ERROR;
            default -> throw new IllegalArgumentException("Invalid HTTP status code: " + code);
        };
    }

    public static String handleResponse(HttpResponse response) {
        return switch (categorize(response.statusCode())) {
            case INFORMATIONAL -> {
                yield "Info [%d]: Processing continues".formatted(response.statusCode());
            }
            case SUCCESS -> {
                yield "Success [%d]: %s".formatted(
                        response.statusCode(),
                        response.body() != null ? response.body() : "No content"
                );
            }
            case REDIRECT -> {
                yield "Redirect [%d]: Following redirect...".formatted(response.statusCode());
            }
            case CLIENT_ERROR -> {
                String advice = switch (response.statusCode()) {
                    case 400 -> "Check request syntax";
                    case 401 -> "Authentication required";
                    case 403 -> "Access denied - check permissions";
                    case 404 -> "Resource not found - verify URL";
                    case 429 -> "Rate limited - retry after backoff";
                    default  -> "Client error";
                };
                yield "Error [%d]: %s".formatted(response.statusCode(), advice);
            }
            case SERVER_ERROR -> {
                String advice = switch (response.statusCode()) {
                    case 500 -> "Internal error - check server logs";
                    case 502 -> "Bad gateway - upstream server issue";
                    case 503 -> "Service unavailable - retry later";
                    case 504 -> "Gateway timeout - upstream too slow";
                    default  -> "Server error";
                };
                yield "Critical [%d]: %s".formatted(response.statusCode(), advice);
            }
        };
    }

    public static void main(String[] args) {
        HttpResponse[] responses = {
            new HttpResponse(200, "{\"status\": \"ok\"}"),
            new HttpResponse(201, "{\"id\": 42}"),
            new HttpResponse(301, null),
            new HttpResponse(400, "Invalid JSON"),
            new HttpResponse(404, null),
            new HttpResponse(500, "NullPointerException"),
            new HttpResponse(503, null)
        };

        for (HttpResponse resp : responses) {
            System.out.println(handleResponse(resp));
        }
    }
}

Example 3: State Machine

public class OrderStateMachine {

    enum OrderState { CREATED, CONFIRMED, PROCESSING, SHIPPED, DELIVERED, CANCELLED }
    enum OrderEvent { CONFIRM, PAY, SHIP, DELIVER, CANCEL }

    // State transition function using switch expressions
    public static OrderState transition(OrderState current, OrderEvent event) {
        return switch (current) {
            case CREATED -> switch (event) {
                case CONFIRM -> OrderState.CONFIRMED;
                case CANCEL  -> OrderState.CANCELLED;
                default      -> throw new IllegalStateException(
                        "Cannot %s order in %s state".formatted(event, current));
            };
            case CONFIRMED -> switch (event) {
                case PAY    -> OrderState.PROCESSING;
                case CANCEL -> OrderState.CANCELLED;
                default     -> throw new IllegalStateException(
                        "Cannot %s order in %s state".formatted(event, current));
            };
            case PROCESSING -> switch (event) {
                case SHIP   -> OrderState.SHIPPED;
                case CANCEL -> OrderState.CANCELLED;
                default     -> throw new IllegalStateException(
                        "Cannot %s order in %s state".formatted(event, current));
            };
            case SHIPPED -> switch (event) {
                case DELIVER -> OrderState.DELIVERED;
                default      -> throw new IllegalStateException(
                        "Cannot %s shipped order".formatted(event));
            };
            case DELIVERED, CANCELLED -> throw new IllegalStateException(
                    "Order in %s state is final -- no transitions allowed".formatted(current));
        };
    }

    public static void main(String[] args) {
        // Happy path
        OrderState state = OrderState.CREATED;
        System.out.println("Initial:    " + state);

        state = transition(state, OrderEvent.CONFIRM);
        System.out.println("Confirmed:  " + state);

        state = transition(state, OrderEvent.PAY);
        System.out.println("Processing: " + state);

        state = transition(state, OrderEvent.SHIP);
        System.out.println("Shipped:    " + state);

        state = transition(state, OrderEvent.DELIVER);
        System.out.println("Delivered:  " + state);

        // Invalid transition
        try {
            transition(state, OrderEvent.CANCEL);
        } catch (IllegalStateException e) {
            System.out.println("Error: " + e.getMessage());
        }

        // Cancellation path
        OrderState order2 = OrderState.CREATED;
        order2 = transition(order2, OrderEvent.CONFIRM);
        order2 = transition(order2, OrderEvent.CANCEL);
        System.out.println("\nCancelled order: " + order2);
    }
}

Example 4: Command Processor

import java.util.*;

public class CommandProcessor {

    record Command(String name, List args) {
        static Command parse(String input) {
            String[] parts = input.trim().split("\\s+");
            String name = parts[0].toLowerCase();
            List args = parts.length > 1
                    ? List.of(Arrays.copyOfRange(parts, 1, parts.length))
                    : List.of();
            return new Command(name, args);
        }
    }

    record CommandResult(boolean success, String message) {}

    // Process commands using switch expressions
    public static CommandResult execute(Command cmd) {
        return switch (cmd.name()) {
            case "help" -> new CommandResult(true, switch (cmd.args().size()) {
                case 0 -> """
                        Available commands:
                          help [command]  - Show help
                          list            - List items
                          add       - Add an item
                          remove    - Remove an item
                          search   - Search items
                          clear           - Clear all items
                          exit            - Exit the program""";
                default -> {
                    String topic = cmd.args().get(0);
                    yield switch (topic) {
                        case "add"    -> "Usage: add  - Adds an item to the list";
                        case "remove" -> "Usage: remove  - Removes an item from the list";
                        case "search" -> "Usage: search  - Searches items by name";
                        default       -> "No help available for: " + topic;
                    };
                }
            });

            case "list" -> new CommandResult(true, "Items: [item1, item2, item3]");

            case "add" -> {
                if (cmd.args().isEmpty()) {
                    yield new CommandResult(false, "Error: 'add' requires an item name");
                }
                String item = String.join(" ", cmd.args());
                yield new CommandResult(true, "Added: " + item);
            }

            case "remove" -> {
                if (cmd.args().isEmpty()) {
                    yield new CommandResult(false, "Error: 'remove' requires an item name");
                }
                String item = String.join(" ", cmd.args());
                yield new CommandResult(true, "Removed: " + item);
            }

            case "search" -> {
                if (cmd.args().isEmpty()) {
                    yield new CommandResult(false, "Error: 'search' requires a query");
                }
                String query = String.join(" ", cmd.args());
                yield new CommandResult(true, "Search results for '" + query + "': [item1, item3]");
            }

            case "clear" -> new CommandResult(true, "All items cleared");

            case "exit" -> new CommandResult(true, "Goodbye!");

            default -> new CommandResult(false,
                    "Unknown command: '%s'. Type 'help' for available commands.".formatted(cmd.name()));
        };
    }

    public static void main(String[] args) {
        String[] inputs = {
            "help",
            "help add",
            "add Buy groceries",
            "list",
            "search groceries",
            "remove Buy groceries",
            "add",
            "unknown command",
            "exit"
        };

        for (String input : inputs) {
            Command cmd = Command.parse(input);
            CommandResult result = execute(cmd);
            String status = result.success() ? "OK" : "ERROR";
            System.out.printf("[%s] > %s%n", status, input);
            System.out.println(result.message());
            System.out.println();
        }
    }
}

12. Best Practices

After working with switch expressions across production Java 17 codebases, here are the guidelines that lead to clean, maintainable, and bug-free code.

1. Default to Arrow Syntax

Unless you have a specific reason to use the colon form (e.g., intentional fall-through in a switch statement), always use the arrow syntax. It eliminates the most common source of switch bugs (missing break) and is more concise.

2. Prefer Switch Expressions Over Statements

If a switch's purpose is to produce a value, make it an expression. This gives you exhaustiveness checking and eliminates the need to declare and assign variables separately.

public class BestPracticesDemo {

    enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }

    public static void main(String[] args) {

        LogLevel level = LogLevel.WARN;

        // BAD: switch statement to compute a value
        String colorBad;
        switch (level) {
            case TRACE, DEBUG -> colorBad = "GRAY";
            case INFO         -> colorBad = "GREEN";
            case WARN         -> colorBad = "YELLOW";
            case ERROR        -> colorBad = "RED";
            case FATAL        -> colorBad = "RED_BOLD";
        }

        // GOOD: switch expression -- cleaner and compiler-checked
        String colorGood = switch (level) {
            case TRACE, DEBUG -> "GRAY";
            case INFO         -> "GREEN";
            case WARN         -> "YELLOW";
            case ERROR        -> "RED";
            case FATAL        -> "RED_BOLD";
        };

        System.out.println(colorGood);
    }
}

3. Use yield Only When Necessary

Prefer single-expression arrow cases. Only use block bodies with yield when you genuinely need multiple statements -- validation, logging, intermediate computation.

4. Leverage Exhaustiveness

For enum switches, do not add a default case. Let the compiler enforce that all enum constants are handled. This way, if someone adds a new constant, you get a compile error -- not a runtime bug discovered weeks later in production.

5. Throw in Default for Unexpected Values

For non-enum types (String, int), always have a default case. When the default represents truly unexpected input, throw an exception rather than returning a neutral value. Silent failures are worse than loud failures.

6. Keep Cases Simple

If a case body grows beyond 5-10 lines, extract it into a private method. The switch should read like a routing table -- easy to scan.

public class KeepCasesSimple {

    enum RequestType { CREATE, READ, UPDATE, DELETE }

    record Request(RequestType type, String payload) {}
    record Response(int status, String body) {}

    // GOOD: Cases delegate to focused methods
    static Response handle(Request request) {
        return switch (request.type()) {
            case CREATE -> handleCreate(request.payload());
            case READ   -> handleRead(request.payload());
            case UPDATE -> handleUpdate(request.payload());
            case DELETE -> handleDelete(request.payload());
        };
    }

    private static Response handleCreate(String payload) {
        // Validation, business logic, persistence...
        System.out.println("Creating resource: " + payload);
        return new Response(201, "Created");
    }

    private static Response handleRead(String payload) {
        System.out.println("Reading resource: " + payload);
        return new Response(200, "{ \"id\": 1, \"name\": \"item\" }");
    }

    private static Response handleUpdate(String payload) {
        System.out.println("Updating resource: " + payload);
        return new Response(200, "Updated");
    }

    private static Response handleDelete(String payload) {
        System.out.println("Deleting resource: " + payload);
        return new Response(204, "");
    }

    public static void main(String[] args) {
        Request req = new Request(RequestType.CREATE, "{ \"name\": \"Widget\" }");
        Response resp = handle(req);
        System.out.println("Status: " + resp.status() + ", Body: " + resp.body());
    }
}

Summary of Key Rules

  • Arrow syntax (->) should be your default -- no fall-through, no break
  • Switch expressions produce values and enforce exhaustiveness -- use them when computing a result
  • yield returns a value from a block body in a switch expression -- use it only when you need multiple statements
  • Comma-separated labels (case A, B, C ->) replace fall-through stacking -- more explicit and readable
  • Exhaustiveness is your friend -- omit default for enums to get compile-time safety
  • Null guard before the switch -- in Java 17, null still throws NPE
  • Keep cases short -- extract complex logic into methods
  • Throw on unexpected values -- do not silently return defaults for input you did not anticipate

Switch expressions are one of the best quality-of-life improvements in modern Java. They eliminate an entire class of bugs (fall-through), reduce boilerplate (no break statements), and give you compiler-enforced completeness. If you are still writing traditional switch statements in Java 17 code, now is the time to modernize. Every new switch you write should be an expression with arrow syntax unless you have a compelling reason otherwise.

March 1, 2026

Java 17 Text Blocks

1. Introduction

If you have been writing Java for any length of time, you know the pain of constructing multi-line strings. Every JSON payload, every SQL query, every HTML snippet you needed to embed in Java code turned into a war against escape characters, concatenation operators, and broken indentation. A simple 5-line JSON object became a 10-line mess of "{", + "\n", and backslash escapes that nobody wanted to read or maintain.

Consider what it took to embed a simple JSON string before text blocks existed:

// The old way: multi-line strings were painful
String json = "{\n" +
              "  \"name\": \"John Doe\",\n" +
              "  \"age\": 30,\n" +
              "  \"email\": \"john@example.com\",\n" +
              "  \"address\": {\n" +
              "    \"street\": \"123 Main St\",\n" +
              "    \"city\": \"Springfield\"\n" +
              "  }\n" +
              "}";

Count the problems: escaped double quotes everywhere, explicit \n newline characters, concatenation operators on every line, and indentation that has nothing to do with the actual JSON structure. If you needed to change a field, you had to navigate a minefield of escape sequences.

Text blocks solve all of this. They were introduced as a preview feature in Java 13 (JEP 355), refined in Java 14 (JEP 368), and finalized as a permanent feature in Java 15 (JEP 378). In Java 17 — the current long-term support (LTS) release — text blocks are a stable, production-ready tool that every Java developer should know.

A text block is a multi-line string literal that lets you write strings exactly as they appear, without escape characters for newlines or double quotes. The same JSON from above becomes:

// With text blocks: clean, readable, maintainable
String json = """
        {
          "name": "John Doe",
          "age": 30,
          "email": "john@example.com",
          "address": {
            "street": "123 Main St",
            "city": "Springfield"
          }
        }
        """;

No escape characters. No concatenation. The string looks exactly like the JSON it represents. When you need to modify it, you edit it like you would edit a regular JSON file. This is what text blocks bring to the table.

Key benefits of text blocks:

  • Readability — Multi-line strings look like the content they represent
  • No escape characters — Double quotes and newlines work naturally
  • Smart indentation — Java automatically strips common leading whitespace
  • Still a String — Text blocks produce regular java.lang.String objects, fully compatible with all existing String methods
  • Compile-time feature — Zero runtime overhead; the compiler processes text blocks during compilation

2. Basic Syntax

A text block starts with three double-quote characters ("""), followed by optional whitespace and a mandatory line terminator (newline). The content of the text block starts on the next line. The text block ends with another """.

The rules are simple but strict:

  • The opening """ must be followed by a line terminator — you cannot put content on the same line as the opening delimiter
  • The closing """ can be on its own line or at the end of the last content line
  • Everything between the opening line terminator and the closing """ is the content
  • A newline in the source code is a newline in the string
public class TextBlockBasics {
    public static void main(String[] args) {

        // Basic text block
        String greeting = """
                Hello, World!
                Welcome to Java Text Blocks.
                This is a multi-line string.
                """;
        System.out.println(greeting);
        // Output:
        // Hello, World!
        // Welcome to Java Text Blocks.
        // This is a multi-line string.

        // Single line in a text block (legal but unusual)
        String single = """
                Just one line
                """;
        System.out.println(single); // "Just one line\n"

        // Empty text block
        String empty = """
                """;
        System.out.println(empty.isEmpty()); // true (only contains "")
        // Actually empty is just "\n" -- NOT empty!

        // Truly empty-ish text block
        String minimal = """
\
""";
        System.out.println(minimal.isEmpty()); // true (line continuation removes the newline)
    }
}

Important: the opening delimiter rule. The following code will NOT compile because content appears on the same line as the opening """:

// COMPILE ERROR: illegal text block open delimiter sequence
String bad = """Hello""";

// COMPILE ERROR: content on same line as opening delimiter
String alsoBad = """Hello
        World""";

// CORRECT: content starts on the next line
String good = """
        Hello
        World""";

Closing delimiter position matters. Where you place the closing """ affects whether the string ends with a newline:

public class ClosingDelimiterDemo {
    public static void main(String[] args) {

        // Closing """ on its own line: string ENDS with a newline
        String withNewline = """
                Hello
                """;
        System.out.println(withNewline.endsWith("\n")); // true
        // Value: "Hello\n"

        // Closing """ on the last content line: NO trailing newline
        String withoutNewline = """
                Hello""";
        System.out.println(withoutNewline.endsWith("\n")); // false
        // Value: "Hello"

        // This distinction matters when building output
        System.out.println("---");
        System.out.print(withNewline);    // prints "Hello" then newline
        System.out.print(withoutNewline); // prints "Hello" with no newline
        System.out.println("---");
    }
}

Double quotes inside text blocks. One of the biggest quality-of-life improvements is that double quotes no longer need escaping (in most cases):

public class QuotesInTextBlocks {
    public static void main(String[] args) {

        // Double quotes work without escaping
        String html = """
                

Hello, "World"!

"""; System.out.println(html); // You CAN still escape them if you want String escaped = """ He said, \"Hello!\" """; // Three consecutive double quotes must have at least one escaped String tricky = """ This contains \""" three quotes """; // Or break them up: String alsoWorks = """ This contains ""\" three quotes """; } }

3. Indentation Management

Indentation management is where text blocks truly shine, and where most developers get confused the first time they use them. The Java compiler uses a clever algorithm to separate incidental whitespace (indentation caused by your source code formatting) from essential whitespace (indentation that is actually part of the string content).

How it works: The compiler looks at every line of the text block, including the line containing the closing """, and finds the common leading whitespace — the smallest number of leading spaces shared by all non-blank lines. It then strips that many spaces from the beginning of every line. This process is called re-indentation.

Think of it like this: the compiler finds the leftmost non-space character across all lines and makes that column the “zero position.” Everything to its left is incidental and gets removed. Everything to its right is essential and stays.

public class IndentationDemo {
    public static void main(String[] args) {

        // All lines have 16 spaces of leading whitespace (incidental)
        // The compiler strips all 16, leaving no leading spaces
        String noIndent = """
                Line one
                Line two
                Line three
                """;
        // Result:
        // "Line one\nLine two\nLine three\n"

        // Some lines have extra indentation beyond the common baseline
        String withIndent = """
                Parent
                    Child 1
                    Child 2
                        Grandchild
                """;
        // Common leading whitespace: 16 spaces (from "Parent" and closing """)
        // Result:
        // "Parent\n    Child 1\n    Child 2\n        Grandchild\n"

        System.out.println(withIndent);
        // Output:
        // Parent
        //     Child 1
        //     Child 2
        //         Grandchild
    }
}

The Closing Delimiter Position Trick

Here is the key insight that trips up beginners: the closing """ participates in the common-whitespace calculation. By moving the closing delimiter left or right, you control how much indentation remains in the final string.

public class ClosingDelimiterIndent {
    public static void main(String[] args) {

        // Closing """ aligned with content: no indentation in result
        String noIndent = """
                SELECT *
                FROM users
                WHERE active = true
                """;
        System.out.println("--- No indent ---");
        System.out.println(noIndent);
        // SELECT *
        // FROM users
        // WHERE active = true

        // Closing """ moved LEFT of content: content is indented in result
        String indented = """
                SELECT *
                FROM users
                WHERE active = true
            """;
        // The closing """ has 12 spaces, content has 16 spaces
        // Common whitespace = 12, so 16-12 = 4 spaces of indent remain
        System.out.println("--- Indented ---");
        System.out.println(indented);
        //     SELECT *
        //     FROM users
        //     WHERE active = true

        // Closing """ at column zero: all source indentation preserved
        String fullyIndented = """
                SELECT *
                FROM users
                WHERE active = true
""";
        System.out.println("--- Fully indented ---");
        System.out.println(fullyIndented);
        //                 SELECT *
        //                 FROM users
        //                 WHERE active = true
    }
}

Visualization of incidental vs essential whitespace:

Scenario Closing """ Position Result
Aligned with content Same column as content No leading whitespace in output
Left of content Less indented than content Content retains relative indentation
Right of content More indented than content Same as aligned (closing delimiter does not add spaces)
Column zero No indentation at all All source indentation preserved

Blank Lines and Trailing Whitespace

Blank lines within a text block are preserved. However, trailing whitespace on each line is stripped by default. This is an important distinction — Java actively removes spaces and tabs at the end of each line during compilation. If you need trailing spaces, you must use the \s escape sequence (covered in the next section).

public class BlankLinesDemo {
    public static void main(String[] args) {

        // Blank lines are preserved
        String withBlanks = """
                Paragraph one.

                Paragraph two.

                Paragraph three.
                """;
        System.out.println(withBlanks);
        // Output:
        // Paragraph one.
        //
        // Paragraph two.
        //
        // Paragraph three.

        // Trailing whitespace is stripped!
        String trailing = """
                Hello
                World
                """;
        // The spaces after "Hello" and "World" are REMOVED
        System.out.println(trailing.contains("Hello     ")); // false
        System.out.println(trailing.contains("Hello"));       // true
    }
}

4. Escape Sequences

Text blocks support all traditional Java escape sequences (\n, \t, \\, \", etc.) plus two new ones that were introduced specifically for text blocks in Java 14:

Escape Name Purpose
\s Space escape Inserts a single space that is not stripped during trailing whitespace removal
\ (backslash at end of line) Line continuation Suppresses the newline character, joining the next line to the current one

The \s Escape: Preserving Trailing Spaces

Since text blocks strip trailing whitespace by default, the \s escape gives you a way to keep spaces when they matter. The \s translates to a single space character (U+0020), and its presence prevents the trailing-whitespace stripping algorithm from removing spaces on that line — because the line now ends with a non-whitespace escape sequence.

public class SpaceEscapeDemo {
    public static void main(String[] args) {

        // Without \s: trailing spaces are stripped
        String stripped = """
                Name:  John
                Age:   30
                """;
        System.out.println(stripped);
        // "Name:  John\nAge:   30\n" -- trailing spaces gone

        // With \s: trailing spaces are preserved
        String preserved = """
                Name:  John    \s
                Age:   30      \s
                """;
        System.out.println(preserved);
        // "Name:  John     \nAge:   30       \n" -- trailing spaces kept!

        // Useful for fixed-width formatting
        String table = """
                ID    Name          Status\s
                1     Alice         Active\s
                2     Bob           Inactive\s
                3     Charlie       Active\s
                """;
        System.out.println(table);
    }
}

The \ Line Continuation Escape

The backslash at the end of a line suppresses the newline, effectively joining the current line with the next one. This is invaluable when you have a very long line that you want to break in source code for readability, but you need it to be a single line in the actual string.

public class LineContinuationDemo {
    public static void main(String[] args) {

        // Without line continuation: each source line becomes a separate line
        String multiLine = """
                This is a very long sentence that \
                spans multiple lines in source code \
                but produces a single line in the output.""";
        System.out.println(multiLine);
        // "This is a very long sentence that spans multiple lines in source code but produces a single line in the output."

        // Practical use: long SQL query readable in source, single line at runtime
        String sql = """
                SELECT u.id, u.name, u.email, u.created_at, \
                u.updated_at, u.status, u.role \
                FROM users u \
                WHERE u.status = 'ACTIVE' \
                AND u.created_at > '2024-01-01'""";
        System.out.println(sql);
        // Single line: SELECT u.id, u.name, u.email, ...

        // Combining \s and \ for precise control
        String message = """
                Dear Customer,\s\
                your order #12345 has been shipped.\s\
                Expected delivery: 3-5 business days.""";
        System.out.println(message);
        // "Dear Customer, your order #12345 has been shipped. Expected delivery: 3-5 business days."
    }
}

Combining Traditional and New Escapes

Text blocks support all the classic escape sequences alongside the new ones:

public class AllEscapesDemo {
    public static void main(String[] args) {

        String allEscapes = """
                Tab here:\tafter tab
                Newline in middle:
                first\nsecond
                Backslash: \\
                Single quote: \'
                Double quote: \"
                Unicode: \u2603 (snowman)
                Trailing space preserved:\s
                This line continues \
                on the same line.
                """;
        System.out.println(allEscapes);

        // Null character and other rarely used escapes also work
        String special = """
                Backspace: \b
                Form feed: \f
                Carriage return: \r
                Octal: \101 (letter A)
                """;
        System.out.println(special);
    }
}

5. String Methods for Text Blocks

Java introduced three new instance methods on String that are designed to work with text blocks, though they can be used with any string. These methods give you programmatic control over the same operations the compiler performs automatically on text blocks.

Method Introduced Purpose
stripIndent() Java 12 Applies the text-block indentation algorithm to any string
translateEscapes() Java 12 Processes Java escape sequences in a string literal
formatted(Object... args) Java 15 Instance-method version of String.format()

stripIndent()

This method applies the same re-indentation algorithm that the compiler applies to text blocks. It is useful when you load multi-line text from a file or database and want to normalize the indentation.

public class StripIndentDemo {
    public static void main(String[] args) {

        // Simulating text loaded from external source with inconsistent indentation
        String fromFile = "        Line one\n" +
                          "        Line two\n" +
                          "            Line three (indented)\n" +
                          "        Line four\n";

        System.out.println("Before stripIndent():");
        System.out.println(fromFile);
        //         Line one
        //         Line two
        //             Line three (indented)
        //         Line four

        String stripped = fromFile.stripIndent();
        System.out.println("After stripIndent():");
        System.out.println(stripped);
        // Line one
        // Line two
        //     Line three (indented)
        // Line four

        // stripIndent() on a text block has no additional effect
        // because the compiler already stripped incidental whitespace
        String textBlock = """
                Hello
                World
                """;
        String doubleStripped = textBlock.stripIndent();
        System.out.println(textBlock.equals(doubleStripped)); // true
    }
}

translateEscapes()

This method processes Java escape sequences in a string. It is especially useful when reading strings from configuration files or user input where escape sequences appear as literal characters (e.g., the two characters \ and n) and you want to convert them to actual escape characters (e.g., a newline).

public class TranslateEscapesDemo {
    public static void main(String[] args) {

        // Simulating a string read from a config file
        // The file literally contains: Hello\nWorld (6 characters, not a newline)
        String raw = "Hello\\nWorld\\tTab\\\\Backslash";
        System.out.println("Raw: " + raw);
        // Raw: Hello\nWorld\tTab\\Backslash

        String translated = raw.translateEscapes();
        System.out.println("Translated:");
        System.out.println(translated);
        // Hello
        // World	Tab\Backslash

        // Works with all Java escape sequences
        String escapes = "\\t\\n\\\\\\'\\\"\\s".translateEscapes();
        System.out.println("Escapes: [" + escapes + "]");
        // Escapes: [	(tab, newline, backslash, quote, double-quote, space)]
    }
}

formatted()

The formatted() method is an instance-method equivalent of String.format(). Instead of writing String.format(template, args), you can write template.formatted(args). This is particularly clean with text blocks because it lets you chain the format call directly onto the text block.

public class FormattedDemo {
    public static void main(String[] args) {

        String name = "Alice";
        int age = 28;
        String city = "Seattle";

        // Old way: String.format()
        String old = String.format("Name: %s, Age: %d, City: %s", name, age, city);

        // New way: formatted() -- reads more naturally
        String modern = "Name: %s, Age: %d, City: %s".formatted(name, age, city);

        System.out.println(old.equals(modern)); // true

        // With text blocks -- this is where formatted() really shines
        String profile = """
                ================================
                User Profile
                ================================
                Name:     %s
                Age:      %d
                City:     %s
                Status:   %s
                ================================
                """.formatted(name, age, city, "Active");
        System.out.println(profile);

        // Building an HTML email template
        String emailHtml = """
                
                
                    

Welcome, %s!

Your account has been created successfully.

Account details:

  • Username: %s
  • Email: %s
  • Plan: %s

Get started at our dashboard.

""".formatted(name, name.toLowerCase(), "alice@example.com", "Premium", "https://example.com/dashboard"); System.out.println(emailHtml); } }

6. Practical Use Cases

Text blocks transform how you write embedded content in Java. Here are the real-world scenarios where they make the biggest difference.

JSON Strings

Building JSON in Java is one of the most common use cases. With text blocks, the JSON in your code looks exactly like the JSON it produces.

public class JsonTextBlocks {
    public static void main(String[] args) {

        // Static JSON
        String config = """
                {
                    "database": {
                        "host": "localhost",
                        "port": 5432,
                        "name": "myapp_db",
                        "pool": {
                            "min": 5,
                            "max": 20,
                            "timeout": 30000
                        }
                    },
                    "cache": {
                        "enabled": true,
                        "ttl": 3600,
                        "provider": "redis"
                    }
                }
                """;
        System.out.println(config);

        // Dynamic JSON with formatted()
        String userId = "usr_12345";
        String role = "admin";
        double balance = 1500.75;

        String userJson = """
                {
                    "id": "%s",
                    "role": "%s",
                    "balance": %.2f,
                    "permissions": ["read", "write", "delete"],
                    "metadata": {
                        "created": "2024-01-15T10:30:00Z",
                        "lastLogin": "2024-06-20T14:22:00Z"
                    }
                }
                """.formatted(userId, role, balance);
        System.out.println(userJson);
    }
}

SQL Queries

Complex SQL queries are dramatically more readable as text blocks. The query structure is visible at a glance.

public class SqlTextBlocks {
    public static void main(String[] args) {

        // Complex JOIN query
        String query = """
                SELECT
                    u.id,
                    u.username,
                    u.email,
                    p.display_name,
                    COUNT(o.id) AS order_count,
                    SUM(o.total) AS total_spent
                FROM users u
                INNER JOIN profiles p ON u.id = p.user_id
                LEFT JOIN orders o ON u.id = o.user_id
                WHERE u.status = 'ACTIVE'
                    AND u.created_at >= '2024-01-01'
                    AND u.email NOT LIKE '%@test.com'
                GROUP BY u.id, u.username, u.email, p.display_name
                HAVING COUNT(o.id) > 0
                ORDER BY total_spent DESC
                LIMIT 100
                """;
        System.out.println(query);

        // Dynamic query with formatted()
        String tableName = "products";
        String category = "electronics";
        int minPrice = 100;

        String dynamicQuery = """
                SELECT id, name, price, stock
                FROM %s
                WHERE category = '%s'
                    AND price >= %d
                    AND active = true
                ORDER BY price ASC
                """.formatted(tableName, category, minPrice);
        System.out.println(dynamicQuery);

        // CREATE TABLE statement
        String ddl = """
                CREATE TABLE IF NOT EXISTS employees (
                    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
                    first_name  VARCHAR(100) NOT NULL,
                    last_name   VARCHAR(100) NOT NULL,
                    email       VARCHAR(255) NOT NULL UNIQUE,
                    department  VARCHAR(100),
                    salary      DECIMAL(10,2),
                    hire_date   DATE NOT NULL,
                    status      ENUM('ACTIVE', 'INACTIVE', 'TERMINATED') DEFAULT 'ACTIVE',
                    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

                    INDEX idx_email (email),
                    INDEX idx_department (department),
                    INDEX idx_status (status)
                );
                """;
        System.out.println(ddl);
    }
}

HTML Templates

public class HtmlTextBlocks {
    public static void main(String[] args) {

        String title = "Product Catalog";
        String productName = "Wireless Headphones";
        double price = 79.99;

        String html = """
                
                
                
                    
                    
                    %s
                    
                
                
                    

%s

%s

$%.2f

Premium wireless headphones with noise cancellation.

""".formatted(title, title, productName, price); System.out.println(html); } }

XML Configuration

public class XmlTextBlocks {
    public static void main(String[] args) {

        String appName = "MyApp";
        String version = "2.1.0";

        String pom = """
                
                
                    4.0.0

                    com.example
                    %s
                    %s
                    jar

                    
                        
                            org.springframework.boot
                            spring-boot-starter-web
                            3.2.0
                        
                        
                            org.postgresql
                            postgresql
                            42.7.1
                            runtime
                        
                    
                
                """.formatted(appName, version);
        System.out.println(pom);
    }
}

Error Messages and Logging

public class ErrorMessageTextBlocks {
    public static void main(String[] args) {

        String endpoint = "/api/users/123";
        int statusCode = 404;
        String method = "GET";

        String errorMessage = """
                ╔══════════════════════════════════════════╗
                ║          API ERROR REPORT                ║
                ╠══════════════════════════════════════════╣
                ║ Endpoint:    %s
                ║ Method:      %s
                ║ Status:      %d
                ║ Timestamp:   2024-06-20T14:30:00Z
                ╠══════════════════════════════════════════╣
                ║ The requested resource was not found.    ║
                ║ Please verify the resource ID and retry. ║
                ╚══════════════════════════════════════════╝
                """.formatted(endpoint, method, statusCode);
        System.out.println(errorMessage);
    }
}

Regex Patterns

Regular expressions benefit enormously from text blocks because you avoid double-escaping. In a regular string, matching a literal backslash requires "\\\\". In a text block, it is just \\.

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class RegexTextBlocks {
    public static void main(String[] args) {

        // Old way: escape nightmare
        String oldPattern = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}[+-]\\d{2}:\\d{2}";

        // Text block way: still need regex escapes, but no double-escaping for Java
        // Actually, for single-line regex, regular strings work fine.
        // Text blocks shine for COMPLEX, COMMENTED regex using (?x) verbose mode:

        String emailRegex = """
                (?x)                    # Enable verbose mode
                ^                       # Start of string
                [a-zA-Z0-9._%+-]+      # Local part: alphanumeric and special chars
                @                       # The @ symbol
                [a-zA-Z0-9.-]+         # Domain name
                \\.                     # Literal dot
                [a-zA-Z]{2,}           # Top-level domain (2+ chars)
                $                       # End of string
                """;

        Pattern pattern = Pattern.compile(emailRegex);

        String[] testEmails = {
            "user@example.com",
            "john.doe+work@company.co.uk",
            "invalid@",
            "@nodomain.com",
            "valid123@test.org"
        };

        for (String email : testEmails) {
            boolean matches = pattern.matcher(email).matches();
            System.out.printf("%-30s -> %s%n", email, matches ? "VALID" : "INVALID");
        }
    }
}

7. Text Blocks vs String Concatenation

Let us put text blocks side by side with the old approach. These before/after comparisons make the readability improvement undeniable.

public class BeforeAfterComparison {
    public static void main(String[] args) {

        // ===== EXAMPLE 1: JSON =====

        // BEFORE: String concatenation
        String jsonOld = "{\n" +
                "  \"user\": {\n" +
                "    \"name\": \"John\",\n" +
                "    \"roles\": [\"admin\", \"user\"],\n" +
                "    \"settings\": {\n" +
                "      \"theme\": \"dark\",\n" +
                "      \"notifications\": true\n" +
                "    }\n" +
                "  }\n" +
                "}";

        // AFTER: Text block
        String jsonNew = """
                {
                  "user": {
                    "name": "John",
                    "roles": ["admin", "user"],
                    "settings": {
                      "theme": "dark",
                      "notifications": true
                    }
                  }
                }
                """;


        // ===== EXAMPLE 2: SQL =====

        // BEFORE
        String sqlOld = "SELECT e.id, e.name, d.department_name, " +
                "COUNT(p.id) AS project_count " +
                "FROM employees e " +
                "JOIN departments d ON e.dept_id = d.id " +
                "LEFT JOIN projects p ON e.id = p.lead_id " +
                "WHERE e.status = 'ACTIVE' " +
                "GROUP BY e.id, e.name, d.department_name " +
                "ORDER BY project_count DESC";

        // AFTER
        String sqlNew = """
                SELECT e.id, e.name, d.department_name,
                       COUNT(p.id) AS project_count
                FROM employees e
                JOIN departments d ON e.dept_id = d.id
                LEFT JOIN projects p ON e.id = p.lead_id
                WHERE e.status = 'ACTIVE'
                GROUP BY e.id, e.name, d.department_name
                ORDER BY project_count DESC
                """;


        // ===== EXAMPLE 3: Multi-line error message =====

        // BEFORE
        String errorOld = "Error: Invalid configuration detected.\n" +
                "  - Property 'database.url' is required but missing.\n" +
                "  - Property 'server.port' must be between 1024 and 65535.\n" +
                "  - Property 'cache.ttl' must be a positive integer.\n" +
                "\n" +
                "Please check your application.properties file and fix the above issues.";

        // AFTER
        String errorNew = """
                Error: Invalid configuration detected.
                  - Property 'database.url' is required but missing.
                  - Property 'server.port' must be between 1024 and 65535.
                  - Property 'cache.ttl' must be a positive integer.

                Please check your application.properties file and fix the above issues.""";


        // They produce the same strings
        System.out.println(jsonOld.equals(jsonNew.stripTrailing()));
        System.out.println(errorOld.equals(errorNew));
    }
}

Readability comparison summary:

Aspect String Concatenation Text Block
Escape characters Required for " and \n Not needed for quotes or newlines
Newlines Explicit \n on every line Automatic from source line breaks
Concatenation operators + on every line None needed
Visual match to output Obscured by syntax noise Content looks like the output
Maintenance effort High — easy to break escaping Low — edit like a text file
Copy-paste from external tool Requires reformatting Paste directly, usually works

8. Text Blocks with String.format and formatted()

In real applications, strings rarely contain only static content. You need to inject dynamic values — user names, timestamps, computed quantities. Text blocks work seamlessly with both String.format() and the newer formatted() method.

public class DynamicTextBlocks {
    public static void main(String[] args) {

        // === Method 1: String.format() ===
        String template1 = """
                Dear %s,

                Your order #%d has been confirmed.
                Total: $%.2f
                Estimated delivery: %s

                Thank you for shopping with us!
                """;
        String email1 = String.format(template1, "Alice", 98765, 249.99, "March 15, 2024");
        System.out.println(email1);

        // === Method 2: formatted() -- cleaner, more fluent ===
        String email2 = """
                Dear %s,

                Your subscription to the %s plan is now active.
                Monthly charge: $%.2f
                Next billing date: %s

                Manage your subscription at: %s
                """.formatted("Bob", "Premium", 29.99, "April 1, 2024", "https://example.com/account");
        System.out.println(email2);

        // === Method 3: String.replace() for named placeholders ===
        String template3 = """
                {
                    "event": "{{EVENT_TYPE}}",
                    "user": "{{USER_ID}}",
                    "timestamp": "{{TIMESTAMP}}",
                    "payload": {
                        "action": "{{ACTION}}",
                        "resource": "{{RESOURCE}}"
                    }
                }
                """;
        String event = template3
                .replace("{{EVENT_TYPE}}", "user.action")
                .replace("{{USER_ID}}", "usr_abc123")
                .replace("{{TIMESTAMP}}", "2024-06-20T10:30:00Z")
                .replace("{{ACTION}}", "delete")
                .replace("{{RESOURCE}}", "document_456");
        System.out.println(event);

        // === Method 4: MessageFormat for positional arguments ===
        // (useful when you need to reuse the same argument)
        // import java.text.MessageFormat;
        String template4 = """
                Hello {0}! Welcome back, {0}.
                You have {1} unread messages.
                Last login: {2}
                """;
        String greeting = java.text.MessageFormat.format(
                template4, "Charlie", 5, "yesterday");
        System.out.println(greeting);
    }
}

Building a Template Engine with Text Blocks

Here is a practical example of building a simple template engine using text blocks and replace():

import java.util.Map;

public class SimpleTemplateEngine {

    private final String template;

    public SimpleTemplateEngine(String template) {
        this.template = template;
    }

    public String render(Map variables) {
        String result = template;
        for (Map.Entry entry : variables.entrySet()) {
            result = result.replace("${" + entry.getKey() + "}", entry.getValue());
        }
        return result;
    }

    public static void main(String[] args) {

        // Define the template using a text block
        SimpleTemplateEngine engine = new SimpleTemplateEngine("""
                
                
                ${title}
                
                    

Welcome, ${username}!

Account type: ${accountType}

Member since: ${memberSince}

Credits remaining: ${credits}

${footerText}

"""); // Render with variables String html = engine.render(Map.of( "title", "My Dashboard", "username", "Alice", "accountType", "Premium", "memberSince", "January 2023", "credits", "1,250", "footerText", "Copyright 2024 Example Corp." )); System.out.println(html); } }

9. Common Pitfalls

Text blocks are intuitive once you understand the rules, but there are several traps that catch developers — especially during the first few weeks of using them.

Pitfall 1: Trailing Whitespace Gets Stripped

This is the most common surprise. Spaces and tabs at the end of each line in a text block are silently removed during compilation. If your string depends on trailing spaces (e.g., fixed-width formatting, test assertions), the output will not match your expectations.

public class PitfallTrailingWhitespace {
    public static void main(String[] args) {

        // You might expect 10-char wide columns, but trailing spaces are stripped
        String table = """
                Name      Age       City
                Alice     30        Seattle
                Bob       25        Portland
                """;

        // Actual content: "Name      Age       City\nAlice     30        Seattle\n..."
        // The trailing spaces after "City", "Seattle", "Portland" are GONE

        // FIX: Use \s at the end of each line
        String fixedTable = """
                Name      Age       City      \s
                Alice     30        Seattle   \s
                Bob       25        Portland  \s
                """;
        // Now trailing spaces are preserved because \s prevents stripping
    }
}

Pitfall 2: Missing Trailing Newline

Whether your string ends with a newline depends on where you place the closing """. This matters in tests and when comparing strings.

public class PitfallMissingNewline {
    public static void main(String[] args) {

        // CAREFUL: These two produce DIFFERENT strings
        String withNewline = """
                Hello""";       // No trailing newline -- value is "Hello"

        String alsoWithNewline = """
                Hello
                """;            // Has trailing newline -- value is "Hello\n"

        System.out.println(withNewline.equals(alsoWithNewline));     // false!
        System.out.println(withNewline.length());                     // 5
        System.out.println(alsoWithNewline.length());                 // 6

        // This can break assertEquals in tests:
        // assertEquals("Hello\n", textBlock); // might fail if closing """ is inline
    }
}

Pitfall 3: Unexpected Indentation

If you do not understand the common-whitespace-removal algorithm, you might get more or fewer leading spaces than you expect.

public class PitfallIndentation {
    public static void main(String[] args) {

        // SURPRISE: This has NO indentation in the output
        String noIndent = """
                {
                    "key": "value"
                }
                """;
        // Common whitespace = 16 spaces (all lines have at least 16)
        // Result: "{\n    \"key\": \"value\"\n}\n"
        // The 16 spaces are stripped; the 4 extra spaces on "key" remain

        // SURPRISE: This has 4 spaces of indentation on ALL lines
        String withIndent = """
                {
                    "key": "value"
                }
            """;
        // Common whitespace = 12 (from closing """)
        // All content lines have 16 spaces, so 16-12 = 4 remain
        // Result: "    {\n        \"key\": \"value\"\n    }\n"

        // TIP: To control indentation precisely, focus on the closing """
        System.out.println("--- No indent ---");
        System.out.println(noIndent);
        System.out.println("--- With indent ---");
        System.out.println(withIndent);
    }
}

Pitfall 4: Three Consecutive Quotes in Content

If your content contains three or more consecutive double-quote characters, you must escape at least one of them. Otherwise, the compiler interprets them as the closing delimiter.

public class PitfallConsecutiveQuotes {
    public static void main(String[] args) {

        // COMPILE ERROR: the compiler sees """ as the closing delimiter
        // String bad = """
        //         She said """Hello""" loudly
        //         """;

        // FIX 1: Escape the first quote of the triple
        String fix1 = """
                She said \"""Hello\""" loudly
                """;

        // FIX 2: Escape the last quote before the triple
        String fix2 = """
                She said ""\"""Hello""\""" loudly
                """;

        // In practice, three consecutive quotes are rare in JSON/SQL/HTML,
        // so this pitfall usually only matters for documentation or test strings.
    }
}

Pitfall 5: Mixing Tabs and Spaces

The common-whitespace algorithm treats tabs and spaces as different characters. If some lines use tabs for indentation and others use spaces, the common prefix will be shorter than you expect, resulting in extra whitespace in the output. Always use consistent indentation — ideally spaces only — within text blocks.

public class PitfallMixedIndentation {
    public static void main(String[] args) {

        // If your IDE mixes tabs and spaces, the result may surprise you.
        // Stick to one indentation style within text blocks.

        // All spaces -- predictable
        String clean = """
                Line one
                Line two
                Line three
                """;

        // Tip: Configure your IDE to insert spaces instead of tabs
        // IntelliJ: Settings > Editor > Code Style > Java > Use tab character: OFF
        // VS Code: "editor.insertSpaces": true
    }
}

10. Best Practices

After working with text blocks across production codebases, here are the guidelines that lead to clean, maintainable code.

When to Use Text Blocks

Use Text Blocks When Use Regular Strings When
String spans 2+ lines String fits on one line
Content has double quotes (JSON, HTML, XML) No special characters
Readability is improved by visual structure String is short and simple
You are embedding another language (SQL, JSON, YAML) Dynamic string built from variables
Test fixtures and expected output Simple error messages or labels

Formatting Conventions

public class BestPractices {

    // GOOD: Closing """ aligned with content for no extra indentation
    private static final String CREATE_USER_SQL = """
            INSERT INTO users (name, email, role)
            VALUES (?, ?, ?)
            """;

    // GOOD: Using formatted() for dynamic content
    public static String buildGreeting(String name, int unreadCount) {
        return """
                Hello, %s!
                You have %d unread messages.
                """.formatted(name, unreadCount);
    }

    // GOOD: Text block constants as static final fields
    private static final String ERROR_TEMPLATE = """
            Error in module: %s
            Message: %s
            Suggestion: %s
            """;

    // GOOD: Using replace() for named templates
    private static final String NOTIFICATION_TEMPLATE = """
            Hi ${name},
            Your ${itemType} "${itemName}" has been ${action}.
            """;

    public static String buildNotification(String name, String itemType,
                                            String itemName, String action) {
        return NOTIFICATION_TEMPLATE
                .replace("${name}", name)
                .replace("${itemType}", itemType)
                .replace("${itemName}", itemName)
                .replace("${action}", action);
    }

    // AVOID: Don't use text blocks for single-line strings
    // Bad:
    String overkill = """
            Hello, World!
            """;
    // Good:
    String simple = "Hello, World!";

    // AVOID: Don't use text blocks when StringBuilder is more appropriate
    // If you're building a string in a loop, StringBuilder is still the right tool.

    public static void main(String[] args) {

        System.out.println(buildGreeting("Alice", 5));

        System.out.println(buildNotification(
                "Bob", "document", "Q4 Report", "approved"));

        // Text blocks are String objects -- all String methods work
        String json = """
                {
                    "key": "value"
                }
                """;
        System.out.println("Length: " + json.length());
        System.out.println("Contains key: " + json.contains("key"));
        System.out.println("Uppercase:\n" + json.toUpperCase());
        System.out.println("Lines: " + json.lines().count());
    }
}

Summary of Key Rules

  • Opening """ must be followed by a newline — no content on the same line
  • Closing """ position controls indentation — align it with content for zero indentation
  • Trailing whitespace is stripped — use \s to preserve it
  • Line continuation \ joins lines — great for long single-line strings
  • formatted() is your friend — chain it directly onto text blocks
  • Text blocks produce regular Strings — all existing methods and patterns work
  • Use consistent indentation — spaces only, no tabs mixed with spaces
  • Declare as static final when the text block is a constant template

Text blocks are one of those features that, once you start using, you wonder how you ever lived without. They eliminate an entire category of string-formatting bugs and make your code look like it respects the developer who has to read it next. Start using them for any multi-line string in your codebase — your future self will thank you.

March 1, 2026

Java 11 Removed/Deprecated Features & Migration

1. Introduction

Java 11 was released in September 2018 as the first Long-Term Support (LTS) release after Java 8. This makes it one of the most important upgrade targets in Java history. If your organization skipped Java 9 and 10 (as most did), migrating from Java 8 to Java 11 means dealing with three major versions of changes at once — the module system (Java 9), API removals, deprecated features, and new defaults.

The good news: millions of projects have made this migration successfully. The bad news: it is not a simple recompile. Java 11 removed several APIs that were bundled with the JDK since the early days, and the module system changes how the classpath and class loading work. If you do not prepare, you will hit compilation errors and runtime exceptions that did not exist in Java 8.

This guide covers everything that was removed, deprecated, or changed, and provides the exact replacements and fixes you need. Here is a high-level overview:

Category What Changed Impact Level
Java EE Modules JAXB, JAX-WS, CORBA, javax.activation, javax.annotation removed High — breaks most enterprise apps
JavaFX Removed from JDK, now a separate project (OpenJFX) High for desktop apps
Nashorn JavaScript Deprecated (removed in Java 15) Medium — affects apps embedding JS
Module System JPMS (Java 9) affects classpath, reflection, internal APIs High — affects most large apps
Deprecated APIs Pack200, Applet API, SecurityManager Low-Medium
Build Tools Maven/Gradle plugins need updates Medium

Target audience: This guide is for teams migrating from Java 8 to Java 11. If you are on Java 9 or 10, many of these changes are already familiar, but the removal of Java EE modules (which were only deprecated in Java 9) is new in Java 11.

2. Removed Java EE Modules

This is the change that breaks the most Java 8 applications. Java has shipped with several Java EE APIs since Java 6, bundled as part of the JDK itself. In Java 9, these modules were deprecated. In Java 11, they were completely removed. If your code uses any of these APIs, it will fail with ClassNotFoundException or NoClassDefFoundError on Java 11.

javax.xml.bind (JAXB)

JAXB (Java Architecture for XML Binding) marshals Java objects to XML and back. It was incredibly common in SOAP web services and enterprise applications. If your code has import javax.xml.bind.*, it will break on Java 11.

Maven replacement:



    jakarta.xml.bind
    jakarta.xml.bind-api
    4.0.2


    org.glassfish.jaxb
    jaxb-runtime
    4.0.5
    runtime

Note on the Jakarta namespace: The Java EE APIs have been transferred to the Eclipse Foundation under the Jakarta EE umbrella. Newer versions use the jakarta.* package prefix instead of javax.*. If you need to maintain the old javax.xml.bind package names for compatibility with existing code, use the older 2.x versions:



    javax.xml.bind
    jaxb-api
    2.3.1


    org.glassfish.jaxb
    jaxb-runtime
    2.3.9
    runtime

Code example — JAXB works the same, just needs the dependency:

import javax.xml.bind.JAXBContext;
import javax.xml.bind.Marshaller;
import javax.xml.bind.annotation.XmlRootElement;
import java.io.StringWriter;

@XmlRootElement
public class Employee {
    private String name;
    private String department;

    // Default constructor required by JAXB
    public Employee() {}

    public Employee(String name, String department) {
        this.name = name;
        this.department = department;
    }

    // Getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }

    public static void main(String[] args) throws Exception {
        Employee emp = new Employee("Alice", "Engineering");

        JAXBContext context = JAXBContext.newInstance(Employee.class);
        Marshaller marshaller = context.createMarshaller();
        marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);

        StringWriter writer = new StringWriter();
        marshaller.marshal(emp, writer);
        System.out.println(writer.toString());
    }
}

// Output (after adding JAXB dependency):
// 
// 
//     Engineering
//     Alice
// 

javax.xml.ws (JAX-WS)

JAX-WS was the API for SOAP web services. If your application generates SOAP clients from WSDL files or provides SOAP endpoints, you need this replacement.

Maven replacement:


    jakarta.xml.ws
    jakarta.xml.ws-api
    4.0.2


    com.sun.xml.ws
    jaxws-rt
    4.0.3
    runtime

javax.activation (JavaBeans Activation Framework)

The Activation Framework is used for MIME type handling and is a dependency of JAXB and JavaMail. Even if you do not use it directly, you may need it as a transitive dependency.

Maven replacement:


    jakarta.activation
    jakarta.activation-api
    2.1.3

javax.annotation

This module provided annotations like @PostConstruct, @PreDestroy, @Resource, and @Generated. These are heavily used in Spring, CDI, and other dependency injection frameworks.

Maven replacement:


    jakarta.annotation
    jakarta.annotation-api
    3.0.0




    javax.annotation
    javax.annotation-api
    1.3.2

CORBA

CORBA (Common Object Request Broker Architecture) was a 1990s-era technology for distributed computing. It was removed entirely from Java 11, and there is no standalone replacement because the technology is essentially dead. If you have legacy CORBA code, your options are limited to staying on Java 8 for that component or migrating to REST/gRPC.

Complete Removed Modules Summary

Removed Module Package What It Did Maven Replacement (groupId:artifactId)
java.xml.bind javax.xml.bind XML-to-Java object binding (JAXB) jakarta.xml.bind:jakarta.xml.bind-api + org.glassfish.jaxb:jaxb-runtime
java.xml.ws javax.xml.ws SOAP web services (JAX-WS) jakarta.xml.ws:jakarta.xml.ws-api + com.sun.xml.ws:jaxws-rt
java.xml.ws.annotation javax.annotation Common annotations (@PostConstruct, etc.) jakarta.annotation:jakarta.annotation-api
java.activation javax.activation MIME type handling jakarta.activation:jakarta.activation-api
java.corba org.omg.CORBA Distributed object computing None — technology is obsolete
java.transaction javax.transaction JTA (Java Transaction API) jakarta.transaction:jakarta.transaction-api

3. Removed JavaFX

JavaFX, the modern GUI toolkit that was meant to replace Swing, was removed from the JDK starting with Java 11. It now lives as an independent open-source project called OpenJFX.

If your application uses JavaFX, you need to add it as an explicit dependency:



    org.openjfx
    javafx-controls
    21.0.5


    org.openjfx
    javafx-fxml
    21.0.5




    org.openjfx
    javafx-maven-plugin
    0.0.8
    
        com.example.App
    

For Gradle:

// build.gradle
plugins {
    id 'org.openjfx.javafxplugin' version '0.1.0'
}

javafx {
    version = "21.0.5"
    modules = ['javafx.controls', 'javafx.fxml']
}

The OpenJFX project is actively maintained and continues to receive updates. Your existing JavaFX code should work with minimal changes — the main effort is adding the dependency and configuring the module path.

4. Removed Nashorn JavaScript Engine

The Nashorn JavaScript engine (javax.script with engine name “nashorn”) was deprecated in Java 11 (JEP 335) and fully removed in Java 15. Nashorn was introduced in Java 8 as a replacement for the even older Rhino engine, but maintaining a full JavaScript engine inside the JDK proved impractical given how quickly JavaScript evolves.

Alternatives:

Alternative Description Best For
GraalJS JavaScript engine from GraalVM project Drop-in replacement for Nashorn, modern ECMAScript support
J2V8 Java bindings for Google V8 engine Performance-critical JavaScript execution
Rhino Mozilla’s JavaScript engine (standalone) Legacy compatibility

If you are using Nashorn to evaluate JavaScript expressions or run scripts, GraalJS is the recommended replacement:



    org.graalvm.js
    js
    23.0.3


    org.graalvm.js
    js-scriptengine
    23.0.3
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;

public class JavaScriptExample {
    public static void main(String[] args) throws Exception {
        // With GraalJS on the classpath, this uses GraalJS instead of Nashorn
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("graal.js");

        if (engine != null) {
            Object result = engine.eval("1 + 2 + 3");
            System.out.println("Result: " + result);  // Result: 6
        } else {
            System.out.println("GraalJS engine not found. Add graaljs dependency.");
        }
    }
}

5. Removed java.se.ee Aggregator Module

The java.se.ee module was an aggregator module that included all the Java EE modules listed above (JAXB, JAX-WS, CORBA, etc.). In Java 9, it was deprecated. In Java 11, it was removed along with all the modules it aggregated.

If your module-info.java contains requires java.se.ee;, remove it and add explicit dependencies for the specific modules you need (using the Maven dependencies shown in section 2).

6. Deprecated Features

These features were deprecated in Java 11 but not yet removed. They will be removed in future Java versions:

Pack200 Tools and API

Pack200 was a compression scheme for JAR files. It was deprecated in Java 11 (JEP 336) and removed in Java 14. The pack200 and unpack200 command-line tools and the java.util.jar.Pack200 API are gone.

Impact: If your build process or deployment pipeline uses Pack200 compression, switch to standard ZIP/JAR compression or use jlink to create custom runtime images (which are smaller than compressed JARs anyway).

Applet API

The Applet API (java.applet.Applet) was deprecated in Java 9 and carries a forRemoval=true annotation. Browsers dropped support for Java applets years ago. If you still have applet-based code, migrate to Java Web Start (also deprecated) or web-based alternatives.

SecurityManager

SecurityManager was not deprecated in Java 11 itself, but it was deprecated for removal in Java 17 (JEP 411). If you rely on SecurityManager for sandboxing, start planning your migration now. Java is moving toward other security mechanisms (process isolation, containers, etc.).

7. Module System Considerations

The Java Platform Module System (JPMS), introduced in Java 9, is the biggest architectural change in Java’s history. While you do not have to modularize your application to run on Java 11, the module system still affects classpath-based applications in important ways.

The Unnamed Module and the Classpath

If you do not create a module-info.java, your entire application runs in the unnamed module. The unnamed module can access all exported packages from all named modules, so most code works without changes. However:

// This worked in Java 8 but may not in Java 11:
// Accessing internal JDK APIs
import sun.misc.Unsafe;           // Encapsulated in java.base
import sun.misc.BASE64Encoder;     // Removed entirely

// Fix: Use the public API equivalents
import java.util.Base64;           // Replacement for BASE64Encoder/Decoder
// For Unsafe, use VarHandle (Java 9+) or MethodHandles

Common Module System Flags

If your application or its dependencies access internal JDK APIs, you may need these JVM flags:

Flag Purpose Example
--add-modules Add a module to the module graph --add-modules java.sql
--add-opens Open a package for deep reflection (setAccessible) --add-opens java.base/java.lang=ALL-UNNAMED
--add-exports Export a package to another module --add-exports java.base/sun.nio.ch=ALL-UNNAMED
--add-reads Add a read edge between modules --add-reads mymodule=java.logging
--illegal-access Control illegal reflective access (removed in Java 17) --illegal-access=permit (Java 11-16 only)

Example: Running a Framework That Uses Reflection

Many frameworks (Spring, Hibernate, Jackson) use deep reflection to access private fields. On Java 11, you may see warnings like:

// Warning you may see at runtime:
// WARNING: An illegal reflective access operation has occurred
// WARNING: Illegal reflective access by org.springframework.core.io.support...
// WARNING: Please consider reporting this to the maintainers of org.springframework...

// Fix: Add --add-opens flags to your JVM startup
// For Spring Boot applications, add to your startup script or JAVA_OPTS:
// java --add-opens java.base/java.lang=ALL-UNNAMED \
//      --add-opens java.base/java.lang.reflect=ALL-UNNAMED \
//      --add-opens java.base/java.util=ALL-UNNAMED \
//      -jar myapp.jar

// For Maven Surefire plugin (test execution):
// 
//     org.apache.maven.plugins
//     maven-surefire-plugin
//     3.2.5
//     
//         
//             --add-opens java.base/java.lang=ALL-UNNAMED
//             --add-opens java.base/java.util=ALL-UNNAMED
//         
//     
// 

Best practice: Update your frameworks and libraries to versions that support Java 11 natively. Modern versions of Spring (5.1+), Hibernate (5.4+), and Jackson (2.10+) have been updated to avoid illegal reflective access.

8. Migration Checklist

Follow this step-by-step checklist when migrating from Java 8 to Java 11:

Phase 1: Preparation

Step Action Details
1 Inventory your dependencies List all Maven/Gradle dependencies and check their Java 11 compatibility. Use mvn dependency:tree to see the full tree.
2 Check for Java EE API usage Search your codebase for import javax.xml.bind, import javax.xml.ws, import javax.annotation. These will break.
3 Check for internal API usage Search for import sun., import com.sun.. These are encapsulated in Java 11.
4 Update build tools Maven 3.5.0+ and Gradle 5.0+ are minimum for Java 11 support. See section 10 for plugin versions.
5 Create a branch Work on a dedicated branch. Keep the Java 8 version running in production until migration is validated.

Phase 2: Compile and Fix

Step Action Details
6 Set compiler target to 11 Update maven-compiler-plugin source and target to 11. See section 10.
7 Add Java EE replacement dependencies Add JAXB, JAX-WS, javax.annotation dependencies from section 2.
8 Fix compilation errors Address removed API usage, internal API access, and deprecated method warnings.
9 Run the test suite Fix any test failures. Pay attention to reflection-based tests and XML processing.

Phase 3: Runtime Validation

Step Action Details
10 Test with Java 11 JVM Run the application and check for IllegalAccessError, ClassNotFoundException, or reflective access warnings.
11 Add --add-opens flags if needed For frameworks that use deep reflection, add the necessary module opens.
12 Test critical paths Test XML processing, serialization, database access, and any integration points.
13 Performance test Java 11 has improved garbage collectors (G1 is now default). Run performance benchmarks.
14 Deploy to staging Run in a production-like environment for at least a week before production deployment.

9. Common Migration Issues and Fixes

This table covers the most frequently encountered issues when migrating from Java 8 to Java 11:

Issue Error Message Fix
JAXB missing ClassNotFoundException: javax.xml.bind.JAXBContext Add jakarta.xml.bind:jakarta.xml.bind-api + org.glassfish.jaxb:jaxb-runtime
JAX-WS missing ClassNotFoundException: javax.xml.ws.Service Add jakarta.xml.ws:jakarta.xml.ws-api + com.sun.xml.ws:jaxws-rt
@PostConstruct missing ClassNotFoundException: javax.annotation.PostConstruct Add jakarta.annotation:jakarta.annotation-api
sun.misc.BASE64 removed ClassNotFoundException: sun.misc.BASE64Encoder Replace with java.util.Base64.getEncoder()
sun.misc.Unsafe IllegalAccessError Use --add-opens java.base/jdk.internal.misc=ALL-UNNAMED or migrate to VarHandle
Reflective access warning WARNING: An illegal reflective access operation has occurred Update the library, or add --add-opens for the specific package
JavaFX missing ClassNotFoundException: javafx.application.Application Add OpenJFX dependencies (see section 3)
Nashorn missing ScriptEngine is null Add GraalJS dependency (see section 4)
Lombok compilation error Various annotation processor errors Update Lombok to 1.18.4+ (supports Java 11)
JaCoCo code coverage fails IllegalArgumentException: Unsupported class file major version Update JaCoCo to 0.8.3+
Mockito reflection error InaccessibleObjectException Update Mockito to 2.23+ or add --add-opens
Spring Boot startup error Various CGLIB/ASM errors Update to Spring Boot 2.1+ (first version with full Java 11 support)

Replacing sun.misc.BASE64Encoder

This is one of the most common fixes. The old internal API was never part of the public specification:

import java.util.Base64;

public class Base64Migration {
    public static void main(String[] args) {
        String original = "Hello, Java 11!";

        // --- BEFORE: Java 8 internal API (REMOVED in Java 11) ---
        // sun.misc.BASE64Encoder encoder = new sun.misc.BASE64Encoder();
        // String encoded = encoder.encode(original.getBytes());
        // sun.misc.BASE64Decoder decoder = new sun.misc.BASE64Decoder();
        // byte[] decoded = decoder.decodeBuffer(encoded);

        // --- AFTER: Standard API (Java 8+) ---
        // Encode
        String encoded = Base64.getEncoder().encodeToString(original.getBytes());
        System.out.println("Encoded: " + encoded);

        // Decode
        byte[] decodedBytes = Base64.getDecoder().decode(encoded);
        String decoded = new String(decodedBytes);
        System.out.println("Decoded: " + decoded);

        // URL-safe encoding (for URLs and filenames)
        String urlEncoded = Base64.getUrlEncoder().encodeToString(original.getBytes());
        System.out.println("URL-safe: " + urlEncoded);

        // MIME encoding (with line breaks every 76 chars)
        String mimeEncoded = Base64.getMimeEncoder().encodeToString(
                "A longer string that would benefit from MIME line wrapping".getBytes());
        System.out.println("MIME: " + mimeEncoded);
    }
}

// Output:
// Encoded: SGVsbG8sIEphdmEgMTEh
// Decoded: Hello, Java 11!
// URL-safe: SGVsbG8sIEphdmEgMTEh
// MIME: QSBsb25nZXIgc3RyaW5nIHRoYXQgd291bGQgYmVuZWZpdCBmcm9tIE1JTUUgbGluZSB3cmFw
// cGluZw==

Finding Internal API Usage

Java provides the jdeps tool to scan your JARs for internal API usage. Run this before migrating to identify problems early:

// Command: scan a JAR for internal API dependencies
// jdeps --jdk-internals myapp.jar
//
// Example output:
// myapp.jar -> java.base
//    com.myapp.util.Encoder -> sun.misc.BASE64Encoder  JDK internal API (removed)
//    com.myapp.security.Crypto -> sun.security.ssl.SSLSessionImpl  JDK internal API
//
// Command: check module dependencies
// jdeps --module-path libs --print-module-deps myapp.jar
//
// Example output:
// java.base,java.logging,java.sql,java.xml

10. Build Tool Updates

Your build tools and their plugins must be updated to support Java 11. Here are the minimum versions required:

Maven

Component Minimum Version Recommended Version Notes
Maven itself 3.5.0 3.9.6+ Older versions may not recognize Java 11 bytecode
maven-compiler-plugin 3.8.0 3.12.1+ Required for release flag support
maven-surefire-plugin 2.22.0 3.2.5+ Needed for Java 11 test execution
maven-failsafe-plugin 2.22.0 3.2.5+ Integration test plugin
maven-jar-plugin 3.1.0 3.3.0+ Correct manifest generation
jacoco-maven-plugin 0.8.3 0.8.11+ Code coverage for Java 11 bytecode

Maven compiler configuration for Java 11:


    11
    11
    11



    
        
        
            org.apache.maven.plugins
            maven-compiler-plugin
            3.12.1
            
                11
            
        

        
        
            org.apache.maven.plugins
            maven-surefire-plugin
            3.2.5
            
                
                
                    --add-opens java.base/java.lang=ALL-UNNAMED
                    --add-opens java.base/java.util=ALL-UNNAMED
                
            
        
    

Why use release instead of source/target? The --release flag (Java 9+) is a single setting that simultaneously sets the source level, target level, and restricts the available APIs to those present in the specified Java version. Using source and target alone lets you accidentally use Java 17 APIs even when targeting Java 11, causing runtime errors on Java 11.

Gradle

Component Minimum Version Recommended Version
Gradle itself 5.0 8.5+
Java plugin Built-in N/A (use java or java-library plugin)

Gradle build.gradle configuration for Java 11:

plugins {
    id 'java'
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

// Or using toolchains (Gradle 6.7+, preferred approach):
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(11)
    }
}

// Add module opens for tests if needed
tasks.withType(Test).configureEach {
    jvmArgs(
        '--add-opens', 'java.base/java.lang=ALL-UNNAMED',
        '--add-opens', 'java.base/java.util=ALL-UNNAMED'
    )
}

Common Library Version Requirements

Here are the minimum library versions that support Java 11:

Library Minimum Java 11 Version Notes
Spring Boot 2.1.0 Spring Framework 5.1+ required
Spring Framework 5.1.0 Full Java 11 support
Hibernate ORM 5.4.0 Earlier 5.x versions may work with --add-opens
Jackson 2.10.0 Full module system support
Lombok 1.18.4 Annotation processing updated for Java 11
Mockito 2.23.0 Uses ByteBuddy which needs Java 11 support
JUnit 5 5.4.0 JUnit 4 also works but consider migrating
Log4j 2 2.13.0 Earlier versions have Java 11 issues
Guava 27.0 Module system support added
Apache HttpClient 4.5.13 / 5.1 Consider using Java 11’s built-in HttpClient instead

Docker / CI Configuration

If you build and deploy with Docker, update your base images:

// Dockerfile -- BEFORE (Java 8)
// FROM openjdk:8-jdk-slim
// COPY target/myapp.jar /app/myapp.jar
// CMD ["java", "-jar", "/app/myapp.jar"]

// Dockerfile -- AFTER (Java 11)
// FROM eclipse-temurin:11-jdk-jammy
// COPY target/myapp.jar /app/myapp.jar
// CMD ["java", \
//      "--add-opens", "java.base/java.lang=ALL-UNNAMED", \
//      "-jar", "/app/myapp.jar"]

// For production, use JRE instead of JDK:
// FROM eclipse-temurin:11-jre-jammy
// COPY target/myapp.jar /app/myapp.jar
// CMD ["java", "-jar", "/app/myapp.jar"]

Practical Migration Example

Here is a real-world example showing code that compiles on Java 8 but fails on Java 11, along with the corrected version:

import java.util.Base64;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;

/**
 * This class demonstrates code AFTER migration to Java 11.
 * Comments show what the Java 8 version looked like.
 */
public class MigrationExample {

    // BEFORE (Java 8): Used sun.misc.BASE64Encoder
    // private static String encodeBase64(byte[] data) {
    //     return new sun.misc.BASE64Encoder().encode(data);
    // }

    // AFTER (Java 11): Use java.util.Base64
    private static String encodeBase64(byte[] data) {
        return Base64.getEncoder().encodeToString(data);
    }

    // BEFORE (Java 8): Used javax.xml.bind for hex encoding
    // private static String bytesToHex(byte[] bytes) {
    //     return javax.xml.bind.DatatypeConverter.printHexBinary(bytes);
    // }

    // AFTER (Java 11): Manual hex conversion or HexFormat (Java 17+)
    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

    // BEFORE (Java 8): Verbose file reading
    // private static String readFile(String path) throws Exception {
    //     byte[] bytes = java.nio.file.Files.readAllBytes(
    //         java.nio.file.Paths.get(path));
    //     return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
    // }

    // AFTER (Java 11): One-line file reading
    private static String readFile(String path) throws Exception {
        return Files.readString(Path.of(path));
    }

    // BEFORE (Java 8): Verbose blank check
    // private static boolean isNullOrBlank(String str) {
    //     return str == null || str.trim().isEmpty();
    // }

    // AFTER (Java 11): Using isBlank()
    private static boolean isNullOrBlank(String str) {
        return str == null || str.isBlank();
    }

    // BEFORE (Java 8): Collection to typed array
    // private static String[] toArray(List list) {
    //     return list.toArray(new String[0]);
    // }

    // AFTER (Java 11): Cleaner toArray
    private static String[] toArray(List list) {
        return list.toArray(String[]::new);
    }

    public static void main(String[] args) throws Exception {
        // Demonstrate all migrated methods
        System.out.println("Base64: " + encodeBase64("Hello Java 11".getBytes()));
        System.out.println("Hex: " + bytesToHex("Hi".getBytes()));
        System.out.println("isNullOrBlank(null): " + isNullOrBlank(null));
        System.out.println("isNullOrBlank(\"  \"): " + isNullOrBlank("  "));
        System.out.println("isNullOrBlank(\"hi\"): " + isNullOrBlank("hi"));

        // Write and read a file using Java 11 APIs
        Path tempFile = Path.of("migration-test.txt");
        Files.writeString(tempFile, "Java 11 migration successful!");
        System.out.println("File content: " + readFile("migration-test.txt"));

        // Cleanup
        Files.deleteIfExists(tempFile);

        // Type-safe toArray
        List names = List.of("Alice", "Bob", "Charlie");
        String[] array = toArray(names);
        System.out.println("Array: " + java.util.Arrays.toString(array));
    }
}

// Output:
// Base64: SGVsbG8gSmF2YSAxMQ==
// Hex: 4869
// isNullOrBlank(null): true
// isNullOrBlank("  "): true
// isNullOrBlank("hi"): false
// File content: Java 11 migration successful!
// Array: [Alice, Bob, Charlie]

Garbage Collector Changes

Java 11 made G1 (Garbage First) the default garbage collector, replacing the Parallel GC from Java 8. G1 is optimized for lower pause times at the cost of slightly lower throughput. For most applications, this is the right trade-off. Key differences:

Feature Parallel GC (Java 8 default) G1 GC (Java 11 default)
Optimization goal Maximum throughput Balanced throughput and latency
Pause times Can be long (seconds) Predictable, short pauses (target: 200ms)
Heap size sweet spot Small to medium heaps Medium to large heaps (4GB+)
Best for Batch processing, background jobs Web servers, microservices, interactive apps

If your application is a batch processor or throughput-critical system and you notice performance degradation after migration, you can switch back to the Parallel GC:

// Use Parallel GC (Java 8 behavior)
// java -XX:+UseParallelGC -jar myapp.jar

// Use G1 GC (Java 11 default, explicit for clarity)
// java -XX:+UseG1GC -jar myapp.jar

// Tune G1 pause target (default 200ms)
// java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -jar myapp.jar

// Epsilon GC (no-op collector, Java 11+) -- for benchmarking only
// java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -jar myapp.jar

// ZGC (experimental in Java 11, production-ready in Java 15)
// java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -jar myapp.jar

String Memory Optimization: Compact Strings

Java 9 introduced Compact Strings (enabled by default in Java 11) which stores Latin-1 strings (English, most European languages) using 1 byte per character instead of 2 bytes. This can reduce memory usage by 30-50% for string-heavy applications. This happens automatically — no code changes needed. If you suspect it causes issues (extremely rare), you can disable it with -XX:-CompactStrings.

JDK Distributions

Starting with Java 11, Oracle changed its licensing model. Oracle JDK is no longer free for commercial production use (though Oracle OpenJDK builds are). Many organizations switched to alternative distributions:

Distribution Vendor Free for Production? LTS Support
Eclipse Temurin (Adoptium) Eclipse Foundation Yes Yes (community)
Amazon Corretto Amazon Yes Yes (Amazon-backed)
Azul Zulu Azul Systems Yes (Community Edition) Yes
Red Hat OpenJDK Red Hat Yes (with RHEL) Yes
Oracle OpenJDK Oracle Yes 6 months only
Oracle JDK Oracle No (requires license) Yes (paid)

Recommendation: For most teams, Eclipse Temurin (formerly AdoptOpenJDK) or Amazon Corretto are the best choices for free, production-ready Java 11 distributions with long-term support.

Quick Reference: Migration Commands

// 1. Check current Java version
// java -version

// 2. Scan JARs for internal API usage
// jdeps --jdk-internals myapp.jar
// jdeps --jdk-internals --multi-release 11 myapp.jar

// 3. Find module dependencies
// jdeps --print-module-deps myapp.jar

// 4. Search codebase for removed APIs
// grep -r "import javax.xml.bind" src/
// grep -r "import javax.xml.ws" src/
// grep -r "import javax.annotation" src/
// grep -r "import sun.misc" src/
// grep -r "import com.sun" src/

// 5. Check Maven dependency tree for conflicts
// mvn dependency:tree
// mvn dependency:analyze

// 6. Run tests with Java 11
// mvn clean test -Dmaven.compiler.release=11

// 7. Create custom runtime image (optional, reduces deployment size)
// jlink --add-modules java.base,java.sql,java.logging \
//        --output custom-jre \
//        --strip-debug \
//        --compress 2

Final advice: The Java 8 to 11 migration is a significant effort, but it unlocks access to three years of Java improvements: the module system, new language features (var, improved try-with-resources), new APIs (HttpClient, new String methods, new file I/O), and better performance (G1 GC as default, compact strings, improved JIT compiler). The investment pays for itself quickly, especially since Java 11 is an LTS release supported until at least 2026 (extended support through 2032 from some vendors). Do not wait — the longer you stay on Java 8, the harder the eventual migration becomes.

March 1, 2026