☕ Java

Pattern Matching

Pattern matching, delivered across several JEPs from Java 16 through Java 21 — pattern matching for instanceof (Java 16), records (Java 16, used heavily by record patterns), pattern matching for switch (finalized Java 21), and record patterns (finalized Java 21) — lets a single instanceof check or switch case simultaneously test a value's type and bind it to a correctly-typed local variable, and (with record patterns) recursively destructure record components inline, eliminating the previously mandatory separate explicit cast that always followed a successful instanceof check. This entry covers exactly what redundancy pattern matching for instanceof removes, pattern matching for switch including guarded patterns and null handling, record patterns and nested destructuring, and how exhaustiveness checking extends naturally to sealed type hierarchies under switch pattern matching.

Pattern Matching for instanceof — Removing the Mandatory Redundant Cast

Before Java 16, an instanceof check only ever produced a boolean result — confirming that a value was an instance of a given type, but providing no way to actually use the value as that type without a separate, immediately-following explicit cast, even though the compiler had already verified the type at the instanceof check itself. This redundancy (test the type, then immediately re-state the same type information again in a cast) was unavoidable boilerplate that appeared in essentially every type-checking-then-using code path, and the cast itself, despite following a check that already guaranteed its safety, was indistinguishable at a glance from a genuinely unsafe cast that might throw ClassCastException, requiring a reader to trace back to the preceding instanceof to confirm the cast couldn't actually fail. Java 16 (JEP 394) introduced pattern matching for instanceof: the instanceof expression can now include a pattern variable name directly (obj instanceof String s), and if the instanceof check succeeds, that pattern variable is automatically declared, cast, and bound to the value, ready to use immediately — both the type test and the safe cast are now expressed in a single, unified syntactic construct, with the compiler guaranteeing the pattern variable's type and eliminating any possibility of the redundant-looking-but-actually-safe cast being confused with a genuinely risky one. A notable subtlety is the pattern variable's scope: it is not simply scoped to the immediately following block the way a normal variable declaration would be, but rather to wherever the compiler can prove the instanceof check must have been true for that code to be reachable — this includes the case where the instanceof check appears negated in an if condition that exits early (returns, throws, breaks, continues) when false, since after such an early exit, the only way execution continues is if the original instanceof check passed, letting the pattern variable be used in the remainder of the enclosing block, not merely inside an explicit "then" branch.
Java
// ── Before Java 16instanceof followed by a mandatory, redundant cast ─
Object obj = "hello";
if (obj instanceof String) {
    String s = (String) obj;   // redundant: type already confirmed by instanceof above,
    System.out.println(s.length());   // but the cast must still be written out explicitly
}

// ── Java 16+ — pattern variable bound directly in the instanceof itself ─
if (obj instanceof String s) {
    System.out.println(s.length());   // "s" is already a String — no separate cast at all
}

// ── Pattern variable scope extends past early-exit negated checks ──────
void process(Object obj) {
    if (!(obj instanceof String s)) {
        return;   // if this check is false (obj IS a String), we return here instead
    }
    // Reaching this point PROVES obj instanceof String was true"s" is usable here,
    // even though it was declared inside a NEGATED instanceof condition:
    System.out.println(s.toUpperCase());
}

// ── Combining the pattern check directly into a boolean expression ─────
Object value = 42;
if (value instanceof Integer i && i > 10) {   // pattern variable usable in the SAME condition
    System.out.println("Big integer: " + i);
}

Pattern Matching for switch — Guarded Patterns and Null Handling

Pattern matching for switch, finalized in Java 21 (JEP 441) after preview across several earlier releases, extends switch's case labels to match against a value's type and bind a pattern variable, the same capability instanceof gained in Java 16, but within switch's existing multi-branch structure — letting a chain of "if this type, do X; else if that type, do Y" instanceof checks be expressed instead as a single switch over a type hierarchy, which reads more directly as the type-dispatch logic it actually represents and benefits from switch's exhaustiveness checking (covered under switch expressions and extended further below for sealed types). A pattern case can additionally be refined with a guard — a when clause following the pattern — that adds a further boolean condition beyond the type match itself; the case only matches if both the type pattern matches and the when clause's condition evaluates to true, and if the when condition is false, matching falls through to consider subsequent case labels exactly as if the type pattern hadn't matched at all. This allows fine-grained conditional dispatch (e.g. "this case, but only for positive values") to be expressed within the switch itself, rather than requiring the matched case's body to immediately re-branch with a nested if. A switch's handling of null also changed meaningfully with pattern matching: a traditional switch throws NullPointerException immediately if the selector expression is null, before any case is even considered, which meant null always had to be checked separately, outside and before the switch. Pattern matching for switch allows an explicit case null label, letting null be handled as just another case directly within the switch's own structure — and if no such case null label is present, the traditional behavior (NullPointerException on a null selector) is preserved exactly, so existing switches that don't expect or handle null retain their prior behavior without any silent change.
Java
// ── Before — chained instanceof checks expressing type dispatch ────────
String describeOld(Object obj) {
    if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof String s) {
        return "String of length " + s.length();
    } else if (obj instanceof Double d) {
        return "Double: " + d;
    } else {
        return "Unknown: " + obj;
    }
}

// ── Java 21 — pattern matching for switch, same logic, reads as dispatch
String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case String s -> "String of length " + s.length();
        case Double d -> "Double: " + d;
        default -> "Unknown: " + obj;
    };
}

// ── Guarded patterns — "when" adds a further condition beyond the type ─
String classify(Object obj) {
    return switch (obj) {
        case Integer i when i > 0 -> "Positive integer";
        case Integer i when i < 0 -> "Negative integer";
        case Integer i -> "Zero";                          // falls through here if neither guard matched
        case String s when s.isBlank() -> "Blank string";
        case String s -> "Non-blank string: " + s;
        default -> "Other: " + obj;
    };
}

// ── Explicit "case null"null handled directly within the switch ─────
String describeWithNull(Object obj) {
    return switch (obj) {
        case null -> "It's null!";              // handled explicitly, no separate pre-check needed
        case Integer i -> "Integer: " + i;
        default -> "Non-null: " + obj;
    };
}
// Without an explicit "case null" label, a null selector still throws NPE exactly
// as a traditional switch always has — behavior is unchanged unless opted into

Record Patterns — Nested Destructuring and Sealed-Type Exhaustiveness

Record patterns, finalized alongside pattern matching for switch in Java 21 (JEP 440), extend type patterns specifically for records: rather than merely matching that a value is an instance of a given record type and binding the whole record to one variable, a record pattern can simultaneously destructure the record's components directly in the pattern itself, binding each component to its own appropriately-typed variable in one step — eliminating the separate accessor calls (point.x(), point.y()) that would otherwise be needed immediately after a type-only match to actually get at the record's data. Record patterns can nest arbitrarily: if a record's component is itself another record, the pattern can recursively destructure that inner record's components too, in the same single pattern expression, which is particularly valuable for the kind of nested, tree-shaped data records are naturally suited to representing (an event containing a location containing coordinates, for instance) — flattening what would otherwise be several levels of nested accessor calls into one readable, structurally-shaped pattern that visually mirrors the data's own nested shape. Record patterns combine directly with sealed type exhaustiveness (introduced for switch expressions in Java 17/21 alongside sealed classes/interfaces): because a sealed type declares its complete, fixed set of permitted subtypes, and because record patterns let each of those subtypes be matched and destructured in its own case, a switch over a sealed hierarchy of records can be both exhaustive (compiler-verified, no default needed) and fully destructuring (each case immediately has direct access to the matched subtype's own component values) in a single, compact construct — a combination that, taken together with the var keyword for type inference on individual pattern components, lets fairly sophisticated tree-shaped data processing be expressed remarkably concisely while still being fully statically type-checked and exhaustiveness-verified by the compiler.
Java
// ── Basic record pattern — destructure components directly in the match
record Point(int x, int y) {}

void printCoordinates(Object obj) {
    if (obj instanceof Point(int x, int y)) {   // destructures BOTH components at once
        System.out.println("x=" + x + ", y=" + y);   // no point.x()/point.y() calls needed
    }
}

// ── Nested record patterns — recursive destructuring of nested records ─
record Location(String city, Point coordinates) {}
record Event(String name, Location location) {}

void describeEvent(Object obj) {
    if (obj instanceof Event(String name, Location(String city, Point(int x, int y)))) {
        // Fully destructured THROUGH two levels of nesting in one pattern:
        System.out.println(name + " in " + city + " at (" + x + "," + y + ")");
    }
}

// ── var can be used for individual pattern components ───────────────────
if (obj instanceof Point(var x, var y)) {   // inferred as int, int — still fully type-checked
    System.out.println(x + y);
}

// ── Record patterns + sealed types + switch — exhaustive AND destructuring
sealed interface Shape permits Circle, Rectangle {}
record Circle(Point center, double radius) implements Shape {}
record Rectangle(Point topLeft, Point bottomRight) implements Shape {}

double area(Shape shape) {
    return switch (shape) {
        case Circle(Point(var cx, var cy), var radius) ->
            Math.PI * radius * radius;          // destructured center coords + radius directly
        case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) ->
            Math.abs((x2 - x1) * (y2 - y1));     // both corner points destructured in one pattern
        // no default needed — Circle/Rectangle are Shape's complete permitted set,
        // compiler-verified exhaustive exactly as with non-destructuring sealed switches
    };
}

Related Topics in Java 9 to 21 Features

Module System (JPMS)
The Java Platform Module System (JPMS), introduced in Java 9 under Project Jigsaw, adds a module as a new, higher-level unit of code organization above the package, allowing a JAR-like artifact to explicitly declare which packages it exports for use by other modules, which modules it depends on, and which packages it keeps entirely internal and inaccessible from outside — closing a long-standing gap in the classpath model where every public class in every JAR on the classpath was accessible to every other JAR, regardless of whether that access was intended. JPMS was also the mechanism used to decompose the JDK itself, which had grown into one monolithic rt.jar, into roughly 90 separate modules, enabling smaller custom runtime images and explicit internal-API encapsulation (notably restricting access to sun.* and other internal packages that many libraries had relied on despite warnings). This entry covers the classpath problems JPMS solves, module-info.java syntax and the requires/exports/opens directives, the distinction between the classpath and module path including automatic and unnamed modules, and jlink custom runtime images.
JShell
JShell, introduced in Java 9, is an interactive Read-Eval-Print Loop (REPL) bundled directly with the JDK, allowing Java statements, expressions, method, class, and variable declarations to be entered and evaluated one at a time at an interactive prompt, with immediate feedback — without requiring a class wrapper, a main method, explicit compilation, or a build tool, all of which Java had always required for even the smallest snippet of executable code before Java 9. This solved a long-standing friction point for learning, quick experimentation, and API exploration, where languages like Python or Ruby offered an immediate interactive prompt but Java required the full edit-compile-run cycle even to test a single line. This entry covers what problem JShell solves and how it differs from a script, core REPL usage including implicit variable creation and forward references, snippet management commands, and how to feed JShell external classes and files.
Collection Factory Methods
Collection factory methods — List.of(...), Set.of(...), and Map.of(...)/Map.ofEntries(...) — introduced in Java 9, provide a concise, standard way to create small, fixed-content, genuinely immutable collections in a single expression, replacing the previously common but verbose combination of Arrays.asList(...), Collections.unmodifiableList(...), and manual add() calls, none of which produced a collection that was both concise to write and fully immutable. This entry covers exactly what problem these factories solve compared to the older idioms, the specific immutability and null-rejection guarantees they provide (which are stricter than Collections.unmodifiableXxx), their behavior around duplicate keys/elements, and important caveats around mutability expectations and performance for very small collections.
Private Interface Methods
Private interface methods, introduced in Java 9, allow an interface to declare methods marked private — with a full method body — that are accessible only from within that same interface (from its default methods, its other private methods, and its private static methods), but completely invisible to implementing classes and to any code outside the interface. They close a code-duplication gap left after Java 8's default methods: when an interface has multiple default methods that share common logic, that shared logic previously either had to be duplicated in each default method or exposed as a public default method purely to enable reuse, polluting the interface's actual API surface with a method meant only for internal reuse. This entry covers the specific problem they solve, the distinction between private instance and private static interface methods, the rules governing what they can call and be called from, and how they interact with the broader default-method design Java established in version 8.