3.2 Modern Syntax Overview

Modern Java focuses on clarity, safety, and brevity without sacrificing readability. This section provides a comprehensive reference of everyday constructs you should reach for first in 2025, showing practical migrations from legacy patterns.

Quick Reference: Modern Constructs at a Glance

Construct Use Case Availability
var Local variable type inference Java 10+
Switch expressions Expression-oriented branching with exhaustiveness Java 14+
Text blocks Multi-line string literals (JSON, SQL, HTML) Java 15+
Pattern matching for instanceof Type test + cast + bind in one operation Java 16+
Record patterns Destructure records in patterns Java 21+
Sealed types Closed type hierarchies for exhaustiveness Java 17+
try-with-resources Automatic resource management Java 7+ (enhanced in 9)
Enhanced for loop Iterate collections without boilerplate Java 5+

Core Principles

  1. Use var for local variables where the type is obvious from the right-hand side (factory methods, constructors)
  2. Prefer switch expressions over imperative statements for exhaustiveness and immutability
  3. Use text blocks for multi-line strings—never concatenate with + or \n
  4. Apply pattern matching to eliminate redundant casts and null checks
  5. Embrace try-with-resources for safe resource cleanup without finally blocks

Before vs After: Practical Migrations

1) Switch Expressions with Exhaustiveness

Legacy imperative switches are verbose, error-prone (missing breaks), and don't enforce completeness. Modern switch expressions are concise, safe, and compiler-verified.

Before (statement form):

int codeToPriority(int code) {
    int p;
    switch (code) {
        case 1:
            p = 10; break;
        case 2:
            p = 5; break;
        default:
            p = 0;
    }
    return p;
}

Problems:

  • Requires mutable variable p
  • Easy to forget break (fall-through bugs)
  • Verbose: 10 lines for simple mapping

After (expression form):

int codeToPriority(int code) {
    return switch (code) {
        case 1 -> 10;
        case 2 -> 5;
        default -> 0;
    };
}

Benefits:

  • Immutable: returns directly
  • No fall-through risk (arrow syntax)
  • Concise: 5 lines vs 10
  • Expression can be assigned, returned, or passed as argument

Exhaustiveness with enums:

enum Status { ACTIVE, PENDING, DISABLED }

String statusToColor(Status status) {
    return switch (status) {
        case ACTIVE -> "green";
        case PENDING -> "yellow";
        case DISABLED -> "red";
        // No default needed - compiler verifies all cases covered
    };
}

If you add a new enum value (e.g., ARCHIVED), the compiler forces you to handle it in every switch—preventing runtime errors.

2) Local Variable Type Inference with var

Use var when the type is obvious from the right-hand side, reducing visual noise without hiding intent.

Good uses of var (type is self-evident):

// Factory methods make type clear
var users = List.of("alice", "bob", "charlie");              // List<String>
var config = Map.of("env", "prod", "region", "us-east-1");  // Map<String, String>
var path = Path.of("/data/input.txt");                      // Path
var instant = Instant.now();                                 // Instant

// Constructors with explicit type arguments
var cache = new HashMap<String, User>();                     // HashMap<String, User>
var executor = new ThreadPoolExecutor(4, 8, 60L, 
                                      TimeUnit.SECONDS, 
                                      new LinkedBlockingQueue<>());

// Builder patterns
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com"))
    .header("Authorization", "Bearer " + token)
    .build();

Avoid var when type is ambiguous:

// BAD: What type is result?
var result = compute();           // Is it String? Integer? Optional<T>?

// GOOD: Explicit type when return type is unclear
ComputeResult result = compute();

// BAD: Primitive literals lose clarity
var timeout = 30;                 // Is it int, long, double?

// GOOD: Make intent explicit for numeric literals
int timeoutSeconds = 30;
// OR use type suffix
var timeoutMs = 30_000L;          // Clearly long

Rules for var usage:

  1. Only for local variables: Never in fields, method parameters, or return types
  2. Must have initializer: var x; doesn't compile—need var x = value;
  3. Cannot infer null: var x = null; doesn't compile—need explicit type
  4. Improves readability: Use when it reduces noise, not when it hides types
  5. Combine with diamond operator: var map = new HashMap<>(); (type inferred from diamond)

Diamond operator with var:

// Before Java 10 (redundant type specification)
Map<String, List<Integer>> inventory = new HashMap<String, List<Integer>>();

// Java 7+ (diamond operator)
Map<String, List<Integer>> inventory = new HashMap<>();

// Java 10+ (var + diamond)
var inventory = new HashMap<String, List<Integer>>();  // Type on right

3) Text Blocks for Multi-line Strings

Text blocks eliminate manual escaping and concatenation for multi-line strings, making embedded formats (JSON, SQL, HTML, XML) readable.

Before (manual escaping):

String json = "{\n" +
              "  \"action\": \"create\",\n" +
              "  \"items\": [1, 2, 3]\n" +
              "}";

String sql = "SELECT users.id, users.name, orders.total\n" +
             "FROM users\n" +
             "JOIN orders ON users.id = orders.user_id\n" +
             "WHERE users.status = 'ACTIVE'\n" +
             "ORDER BY orders.total DESC";

Problems:

  • Manual \n insertion
  • Quotes require escaping (\")
  • Hard to visualize the actual output
  • Difficult to maintain (editing requires adjusting all lines)

After (text blocks):

String json = """
    {
      "action": "create",
      "items": [1, 2, 3]
    }
    """;

String sql = """
    SELECT users.id, users.name, orders.total
    FROM users
    JOIN orders ON users.id = orders.user_id
    WHERE users.status = 'ACTIVE'
    ORDER BY orders.total DESC
    """;

Benefits:

  • No manual \n or \" escaping
  • WYSIWYG: code looks like output
  • Easy to copy/paste from external sources
  • Automatic indentation management

Indentation rules:

Text blocks automatically remove common leading whitespace based on the closing delimiter position:

// Closing delimiter position determines indentation level
String html = """
        <html>
          <body>
            <h1>Welcome</h1>
          </body>
        </html>
        """;
// Result: no leading spaces (closing """ at column 0 of its line)

// To preserve indentation, indent the closing delimiter
String indented = """
          Line 1
          Line 2
          """;  // Closing delimiter has 10 spaces
// Result: "Line 1\nLine 2\n" (no leading spaces)

Formatting with formatted():

String name = "Alice";
int age = 30;

String message = """
    {
      "name": "%s",
      "age": %d,
      "registered": true
    }
    """.formatted(name, age);

Common use cases:

  1. JSON payloads:

    String payload = """
        {
          "event": "user.created",
          "data": {
            "id": "%s",
            "email": "%s"
          }
        }
        """.formatted(userId, email);
    
  2. SQL queries:

    String query = """
        SELECT * FROM users
        WHERE status = ?
          AND created_at > ?
        ORDER BY created_at DESC
        LIMIT 100
        """;
    
  3. HTML templates:

    String html = """
        <!DOCTYPE html>
        <html>
          <head><title>%s</title></head>
        <body>
            <h1>%s</h1>
            <p>%s</p>
          </body>
        </html>
        """.formatted(title, heading, content);
    
  4. Test data:

    String csvData = """
        id,name,email
        1,Alice,alice@example.com
        2,Bob,bob@example.com
        3,Charlie,charlie@example.com
        """;
    

4) Pattern Matching for instanceof

Pattern matching eliminates the redundant "test, cast, use" ceremony by combining type checking and binding in a single operation.

Before (manual cast):

Object value = fetch();

if (value instanceof String) {
    String s = (String) value;  // Redundant cast
    if (!s.isBlank()) {
        System.out.println("Length: " + s.length());
    }
}

Problems:

  • Repeat the type (String) twice
  • Manual cast required
  • Scope of s extends beyond where it's safe to use

After (pattern matching):

Object value = fetch();

if (value instanceof String s && !s.isBlank()) {
    System.out.println("Length: " + s.length());
}
// s is not in scope here

Benefits:

  • Type declared once
  • No explicit cast
  • Pattern variable s only exists where the type is guaranteed
  • Can combine with additional conditions using &&

Pattern matching with negation:

if (!(obj instanceof String s)) {
    throw new IllegalArgumentException("Expected string");
}
// s is in scope here (definite assignment after guard)
System.out.println(s.toUpperCase());

Multiple instanceof checks:

Object obj = fetch();

if (obj instanceof Integer i) {
    System.out.println("Integer: " + (i * 2));
} else if (obj instanceof String s) {
    System.out.println("String: " + s.toUpperCase());
} else if (obj instanceof List<?> list) {
    System.out.println("List size: " + list.size());
} else {
    System.out.println("Unknown type");
}

Combining with method calls:

// Before
if (response instanceof ErrorResponse) {
    ErrorResponse error = (ErrorResponse) response;
    if (error.isRetryable()) {
        retry(error.getAttemptCount());
    }
}

// After
if (response instanceof ErrorResponse err && err.isRetryable()) {
    retry(err.getAttemptCount());
}

Edge case: null handling

Object obj = null;

// null never matches instanceof
if (obj instanceof String s) {
    // Never reached when obj is null
}

// To handle null explicitly
if (obj == null) {
    handleNull();
} else if (obj instanceof String s) {
    handleString(s);
}

5) Try-with-resources: Automatic Resource Management

Try-with-resources ensures resources (files, connections, streams) are closed automatically, even if exceptions occur—eliminating verbose finally blocks and resource leak bugs.

Before (manual cleanup with finally):

BufferedReader reader = null;
try {
    reader = Files.newBufferedReader(Path.of("/data/input.txt"));
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (reader != null) {
        try {
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Problems:

  • 17 lines just for resource management
  • Need to track resource in outer scope
  • Finally block requires another try-catch
  • Easy to forget to close or close incorrectly

After (try-with-resources):

Path path = Path.of("/data/input.txt");
try (var reader = Files.newBufferedReader(path)) {
    reader.lines().forEach(System.out::println);
} catch (IOException e) {
    e.printStackTrace();
}

Benefits:

  • 5 lines vs 17
  • Resource closed automatically (even if exception thrown)
  • No manual close() call
  • Supports multiple resources

Multiple resources:

try (var in = new FileInputStream("input.txt");
     var out = new FileOutputStream("output.txt")) {
    in.transferTo(out);
}
// Both streams closed in reverse order automatically

Effectively final resources (Java 9+):

If the resource is already declared, reference it without re-declaration:

BufferedReader reader = Files.newBufferedReader(path);
try (reader) {  // Just reference existing variable
    return reader.readLine();
}
// reader closed automatically

Common resources that implement AutoCloseable:

  • InputStream / OutputStream
  • Reader / Writer
  • Socket / ServerSocket
  • Connection / Statement / ResultSet (JDBC)
  • Scanner
  • Stream<T> (parallel streams, file streams)

Custom AutoCloseable:

class DatabaseTransaction implements AutoCloseable {
    void execute(String sql) { /* ... */ }

    @Override
    public void close() {
        // Commit or rollback transaction
    }
}

try (var tx = new DatabaseTransaction()) {
    tx.execute("INSERT INTO users VALUES (...)");
    // Transaction committed automatically
}

Suppressed exceptions:

If both the try block and close() throw exceptions, the close exception is suppressed:

try (var resource = createResource()) {
    throw new IOException("Processing failed");
} 
// If close() also throws, it's added as suppressed exception
// Access with: exception.getSuppressed()

Real-World Illustration: Modernizing a Controller

Let's modernize a legacy HTTP request handler step-by-step, applying all modern syntax constructs.

Legacy Implementation (Java 8 style)

public class UserController {
    public String handleRequest(String json) {
        // Parse command from JSON (simplified)
        Command cmd = parseCommand(json);

        String result;
        if (cmd == null) {
            result = "{\"error\":\"Invalid command\"}";
        } else if (cmd instanceof CreateUser) {
            CreateUser create = (CreateUser) cmd;
            String email = create.getEmail();
            if (email == null || email.isEmpty()) {
                result = "{\"error\":\"Email required\"}";
            } else {
                result = "{" +
                        "\"result\":\"created\"," +
                        "\"email\":\"" + email + "\"" +
                        "}";
            }
        } else if (cmd instanceof DisableUser) {
            DisableUser disable = (DisableUser) cmd;
            UUID id = disable.getId();
            result = "{" +
                    "\"result\":\"disabled\"," +
                    "\"id\":\"" + id.toString() + "\"" +
                    "}";
        } else {
            result = "{\"error\":\"Unknown command\"}";
        }

        return result;
    }
}

Problems:

  • Mutable result variable
  • Manual type casts
  • String concatenation for JSON
  • Verbose null/empty checks
  • No exhaustiveness checking

Modern Implementation (Java 25 style)

public class UserController {

    sealed interface Command permits CreateUser, DisableUser {}
    record CreateUser(String email) implements Command {}
    record DisableUser(UUID id) implements Command {}

    public String handleRequest(String json) {
        Command cmd = parseCommand(json);

        return switch (cmd) {
            case null -> 
                """
                {"error":"Invalid command"}
                """;

            case CreateUser(var email) when email != null && !email.isBlank() -> 
                """
                {
                  "result": "created",
                  "email": "%s"
                }
                """.formatted(email);

            case CreateUser(_) -> 
                """
                {"error":"Email required"}
                """;

            case DisableUser(var id) -> 
                """
                {
                  "result": "disabled",
                  "id": "%s"
                }
                """.formatted(id);
        };
    }
}

Improvements:

  1. Sealed types: Command is closed hierarchy → compiler enforces exhaustiveness
  2. Records: CreateUser and DisableUser are concise data carriers
  3. Switch expression: Immutable, returns directly
  4. Pattern matching: case CreateUser(var email) destructures record
  5. Guarded patterns: when email != null && !email.isBlank() handles validation
  6. Text blocks: JSON templates are readable
  7. No casts: Pattern matching eliminates type casts

Line count comparison:

  • Legacy: ~35 lines
  • Modern: ~28 lines
  • 20% reduction with improved safety and readability

Progressive Enhancement

You can modernize incrementally:

Step 1: Convert to switch expression

return switch (cmd) {
    case null -> "{\"error\":\"Invalid command\"}";
    case CreateUser c -> handleCreate(c);
    case DisableUser d -> handleDisable(d);
};

Step 2: Add pattern destructuring

case CreateUser(var email) -> handleCreate(email);
case DisableUser(var id) -> handleDisable(id);

Step 3: Replace string concatenation with text blocks

case CreateUser(var email) -> """
    {"result":"created","email":"%s"}
    """.formatted(email);

Step 4: Add guards for validation

case CreateUser(var email) when !email.isBlank() -> ...

Additional Modern Constructs

Enhanced For Loop

Iterate collections without manual iterator management:

// Legacy
for (Iterator<String> it = items.iterator(); it.hasNext(); ) {
    String item = it.next();
    System.out.println(item);
}

// Modern
for (var item : items) {
    System.out.println(item);
}

// Even better: streams for transformations
items.forEach(System.out::println);

Diamond Operator for Generic Inference

// Before Java 7 (redundant type parameters)
List<String> names = new ArrayList<String>();
Map<String, List<Integer>> data = new HashMap<String, List<Integer>>();

// Java 7+ (diamond operator)
List<String> names = new ArrayList<>();
Map<String, List<Integer>> data = new HashMap<>();

// Java 10+ (var + diamond)
var names = new ArrayList<String>();  // Type on right
var data = new HashMap<String, List<Integer>>();

Method References

Replace lambda expressions with method references when they simply delegate to a method:

// Lambda
users.forEach(user -> System.out.println(user));
names.stream().map(name -> name.toUpperCase());

// Method reference (more concise)
users.forEach(System.out::println);
names.stream().map(String::toUpperCase);

// Constructor reference
Stream.of("1", "2", "3")
    .map(Integer::new)  // Equivalent to: s -> new Integer(s)
    .toList();

Compact Number Formatting

// Underscore separators in numeric literals (Java 7+)
long billion = 1_000_000_000L;
int hex = 0xFF_EC_DE_5E;
double pi = 3.14_15_92_65_35;

// Binary literals (Java 7+)
int flags = 0b0001_0010_0100_1000;

Immutable Collections (Java 9+)

// Factory methods create immutable collections
var numbers = List.of(1, 2, 3);           // Immutable List
var config = Map.of("env", "prod",        // Immutable Map
                    "region", "us-east");
var tags = Set.of("java", "modern");      // Immutable Set

// Attempting to modify throws UnsupportedOperationException
numbers.add(4);  // Runtime error!

Stream.toList() (Java 16+)

// Before (verbose)
List<String> filtered = items.stream()
    .filter(s -> s.startsWith("A"))
    .collect(Collectors.toList());

// After (concise)
var filtered = items.stream()
    .filter(s -> s.startsWith("A"))
    .toList();  // Returns immutable list

Guidance: When to Use Modern Constructs

Use var when:

  • ✅ Type is obvious from right-hand side (new, factory method, builder)
  • ✅ Reduces verbosity without hiding intent
  • ❌ Avoid for ambiguous return types
  • ❌ Never in public APIs (fields, parameters, returns)

Use switch expressions when:

  • ✅ You need exhaustiveness checking (enums, sealed types)
  • ✅ Result is used (assigned, returned, passed)
  • ✅ Multiple cases return values
  • ❌ When side effects are primary goal (prefer if/else)

Use text blocks when:

  • ✅ Embedded formats (JSON, SQL, HTML, XML, YAML)
  • ✅ Multi-line strings with special characters
  • ✅ Test data or templates
  • ❌ Single-line strings (use regular strings)

Use pattern matching when:

  • instanceof check followed by cast
  • ✅ Destructuring records or complex data
  • ✅ Switch with multiple type cases
  • ❌ Simple null checks (use == null)

Use try-with-resources when:

  • ✅ Any resource implementing AutoCloseable
  • ✅ Files, streams, connections, sockets
  • ✅ Multiple resources to manage
  • ❌ Non-resource cleanup (use finally or custom logic)

Common Pitfalls and How to Avoid Them

Pitfall 1: Overusing var

// BAD: Type is not obvious
var result = process(input);

// GOOD: Explicit type when return is ambiguous
ProcessResult result = process(input);

Pitfall 2: Forgetting default in switch

// Compiles but throws at runtime if new enum value added
return switch (color) {
    case RED -> "#FF0000";
    case GREEN -> "#00FF00";
    // Missing BLUE - compiles, but NullPointerException if BLUE passed!
};

// Better: Cover all cases or use default
return switch (color) {
    case RED -> "#FF0000";
    case GREEN -> "#00FF00";
    case BLUE -> "#0000FF";
};

Pitfall 3: Text block indentation confusion

// Closing delimiter controls indentation
String bad = """
    Line 1
    Line 2
    """;  // Indentation stripped based on closing """

// To preserve indentation, move closing delimiter
String good = """
    Line 1
    Line 2
""";  // No leading spaces removed

Pitfall 4: Pattern variable scope

if (obj instanceof String s || condition()) {
    // ERROR: s not in scope here (|| means it might not be String)
}

if (obj instanceof String s && !s.isEmpty()) {
    // OK: s in scope (both conditions must be true)
}

Pitfall 5: Mutable var with immutable collections

var list = List.of(1, 2, 3);  // Immutable list
list = new ArrayList<>();      // OK: var is mutable reference
list.add(4);                   // OK: new list is mutable

var list2 = List.of(1, 2, 3);
list2.add(4);                  // ERROR: List.of() returns immutable list

Performance Considerations

Modern syntax has zero or positive performance impact:

  • var: Compile-time only (zero runtime cost)
  • Switch expressions: Same bytecode as switch statements (tableswitch/lookupswitch)
  • Text blocks: Interned at compile time (same as string literals)
  • Pattern matching: Same instanceof check + cast (no overhead)
  • Try-with-resources: Compiler-generated finally block (explicit close())

In fact, modern constructs often improve performance:

  • Switch expressions enable better JIT optimization
  • Immutable collections from List.of() have optimized implementations
  • Pattern matching can eliminate redundant checks

Migration Strategy

Modernize gradually in this order:

  1. Week 1: Convert local variables to var where type is obvious
  2. Week 2: Replace imperative switches with expression form
  3. Week 3: Convert string concatenation to text blocks
  4. Week 4: Add pattern matching to instanceof checks
  5. Week 5: Introduce sealed types for closed hierarchies

Don't attempt a "big bang" rewrite—modern and legacy syntax coexist peacefully.

Conclusion

Modern Java syntax isn't about clever tricks or cryptic shortcuts. It's about:

  • Removing ceremony that obscures intent
  • Making illegal states unrepresentable through exhaustiveness
  • Reducing bugs by eliminating manual casts and resource leaks
  • Improving maintainability with concise, declarative code

Start using these constructs in new code immediately. Migrate existing code incrementally. Within months, your codebase will be more readable, safer, and more maintainable—without sacrificing performance or Java's static typing guarantees.

The next sections dive deeper into type inference, switch expressions, text blocks, and pattern matching with edge cases, advanced examples, and comprehensive best practices.