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:
Is this subclass a leaf node with no further specialization? Use final.
Does this subclass have its own known, fixed set of subtypes? Use sealed with its own permits clause.
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
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 "
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
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.
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.
Prefer sealed interfaces over sealed classes when subtypes do not share state. Interfaces allow records (which are great) and multiple implementation.
Default to final for permitted subclasses. Only use sealed or non-sealed when you have a specific reason.
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 permitsmust 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
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
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
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.
// 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.
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:
Start with equals() methods -- they are the most mechanical and safest to convert.
Convert utility/helper methods next -- methods that process Object parameters.
Tackle type-dispatch chains last -- these may need broader refactoring if the design should use polymorphism instead.
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.
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:
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 = """
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.
public class XmlTextBlocks {
public static void main(String[] args) {
String appName = "MyApp";
String version = "2.1.0";
String pom = """
4.0.0com.example%s%sjarorg.springframework.bootspring-boot-starter-web3.2.0org.postgresqlpostgresql42.7.1runtime
""".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}
""");
// 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:
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.
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:
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.
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.activationjakarta.activation-api2.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.
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.
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:
// 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:
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:
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:
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.
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.