☕ Java

Pattern Matching for switch

Pattern matching for switch, finalized in Java 21 (JEP 441, after preview across Java 17–20), extends switch's case labels beyond plain constant matching to also support type patterns — testing a selector's runtime type and binding a correctly-typed pattern variable directly within the case label — along with guarded patterns (when clauses), explicit null handling via case null, and compiler-verified exhaustiveness over sealed type hierarchies, turning switch into a general-purpose, type-dispatching control structure rather than the constant-comparison-only construct it had been since Java 1.0. This entry covers exactly what kind of chained instanceof/else-if logic this replaces and why that logic was error-prone, type patterns versus traditional constant case labels and how they interact in one switch, guarded patterns and case label ordering/dominance rules, and the explicit case null label alongside how unconditional vs conditional patterns affect exhaustiveness and the need for a default.

Motivation — Replacing Chained instanceof/else-if Type Dispatch

Since Java 1.0, switch's case labels could only test for equality against a constant value — a literal int/char/String, or (since Java 5) an enum constant — never a type. Code that needed to dispatch behavior based on a value's runtime type, rather than its equality to a specific constant, had no choice but to fall back to a chain of instanceof checks inside if/else-if branches, a pattern that, even after pattern matching for instanceof removed the redundant cast in Java 16, remained structurally a sequence of independent boolean tests rather than a single, unified construct — meaning the compiler had no way to verify that the chain actually covered every relevant case (there's no equivalent of switch's exhaustiveness checking for a plain if/else-if chain), and a long chain of else-if branches checking different types is also visually harder to scan than a list of case labels, since each check repeats the full if/else-if boilerplate rather than presenting as a clean, parallel list of alternatives. Java 21's pattern matching for switch (JEP 441) directly addresses this by allowing switch's case labels to be type patterns — case Integer i, case String s, and so on — testing the selector's runtime type and binding a pattern variable in one step, exactly mirroring what pattern matching for instanceof already provided, but within switch's existing branching structure. This converts what had to be a chain of independent if/else-if checks into a single, scannable list of case alternatives, gains the compiler's exhaustiveness-checking machinery (covered in depth below), and reads as what the logic actually is — dispatch based on type — rather than as a sequence of incidentally-related boolean tests.
Java
// ── Before Java 21 — type dispatch via chained instanceof/else-if ──────
String describeOld(Object obj) {
    if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof Long l) {
        return "Long: " + l;
    } 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;
    }
}
// No compiler-verified guarantee that every relevant type was actually
// considered — purely a sequence of independent if/else-if boolean checks

// ── Java 21 — pattern matching for switch: a single, scannable dispatch ─
String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case Long l    -> "Long: " + l;
        case String s  -> "String of length " + s.length();
        case Double d  -> "Double: " + d;
        default        -> "Unknown: " + obj;
    };
}
// Reads directly as "dispatch on type" — and (as covered below) gains
// real compiler exhaustiveness checking when the selector is a sealed type

Type Patterns, Constant Labels in the Same Switch, and Dominance Rules

A single switch can freely mix traditional constant case labels and type patterns, since they're not mutually exclusive — a case matching a literal value and a case matching a type pattern are simply two different kinds of case label, evaluated in the order they're written, with the first matching label (of either kind) determining which arm executes. This allows, for instance, special-casing a few specific constant values before falling back to type-based dispatch for everything else of a given type, all within one switch. Because case labels are evaluated top-to-bottom and the first match wins, the compiler enforces a dominance rule for type patterns specifically: a case label is illegal (a compile error, not merely a runtime concern) if an earlier case label in the same switch would always match every value that this later case label could ever match — for example, placing case Object o before case String s is an error, since Object matches absolutely everything a String could also match, making the later, more specific String case unreachable code by construction; the compiler catches this directly rather than allowing genuinely dead code to compile silently. This means type-pattern cases in a switch should generally be ordered from most specific to least specific, mirroring the same ordering discipline that chained instanceof/else-if code always implicitly required, but now actually checked by the compiler rather than left to the author's care. A type pattern case can also be combined with record pattern destructuring (covered separately) directly in the same case label, and multiple type-pattern cases can share logic the same way constant cases can via multiple comma-separated labels — though when binding a pattern variable, each label in a shared case must independently make sense for that variable's declared type, which in practice means pattern variables are typically not combined across labels that bind different incompatible types.
Java
// ── Mixing constant labels and type patterns in one switch ─────────────
String classify(Object obj) {
    return switch (obj) {
        case 0 -> "zero literal";                  // constant label — still works as always
        case Integer i -> "Integer: " + i;          // type pattern — for any other int value
        case String s when s.isEmpty() -> "empty string";   // guarded type pattern
        case String s -> "String: " + s;
        default -> "Other: " + obj;
    };
}

// ── Dominance rule — compiler rejects an earlier label that swallows a later one
Object value = "test";
String result = switch (value) {
    case Object o -> "Any object: " + o;     // matches EVERYTHING — placed first
    // case String s -> "String: " + s;       // COMPILE ERROR if uncommented:
    // "this case label is dominated by a preceding case label"String is
    // unreachable here, since "Object o" above already matches every String too
    default -> "unreachable anyway";
};

// ── Correct ordering — most specific pattern first, general pattern last ─
String resultFixed = switch (value) {
    case String s -> "String: " + s;          // more specific — checked FIRST
    case Object o -> "Any object: " + o;       // general fallback — checked LAST, legal here
};

case null, Guarded Patterns, and Exhaustiveness Implications

Traditional switch has always thrown NullPointerException immediately upon encountering a null selector, before any case label is even considered — a behavior pattern matching for switch deliberately preserves by default for full backward compatibility, but which can now be explicitly opted out of via a case null label, letting null be handled as a genuine, ordinary case directly within the switch's structure rather than requiring a separate null check written entirely outside and before the switch. A case null label can also be combined with default using case null, default — matching either a null selector or any value not otherwise matched by an earlier case, in one combined label — which is specifically useful when null and the unhandled-default case should both receive the same treatment. A guarded pattern (case Type t when condition) only matches if both the type pattern matches and the when clause's boolean condition evaluates to true; if the type matches but the guard is false, the switch continues evaluating subsequent case labels exactly as though the type pattern itself hadn't matched, rather than treating it as an error or skipping the rest of the switch. This distinction between unconditional patterns (no when clause, or a when clause the compiler can statically determine is always true) and conditional patterns (a when clause whose truth can't be statically guaranteed) matters directly for exhaustiveness: a guarded pattern, because its guard might be false at runtime for some otherwise-matching value, does not by itself let the compiler treat that type as "fully handled," so a switch relying solely on guarded patterns over a sealed type's permitted subtypes generally still needs either an unconditional case for that subtype or a default to remain provably exhaustive, even if every permitted subtype technically appears in some guarded case label. Exhaustiveness checking for type-pattern switches follows the same principle established for sealed types under switch expressions generally: if the selector's type is sealed (or an enum), and every permitted subtype (or every enum constant) is covered by some unconditional case, default may be omitted; otherwise — for non-sealed, non-enum selector types, or where coverage relies on guarded patterns that the compiler cannot statically prove are unconditionally exhaustive — default remains mandatory, and omitting it is a compile-time error.
Java
// ── case null — handled explicitly within the switch itself ────────────
String describe(Object obj) {
    return switch (obj) {
        case null -> "Got null";                   // explicit opt-in; no separate pre-check needed
        case Integer i -> "Integer: " + i;
        default -> "Something else: " + obj;
    };
}
// Without "case null", a null selector here would still throw NullPointerException,
// exactly as a traditional switch always has — unchanged unless opted into

// ── case null, default — combined label for shared null/default handling
String describeCombined(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case null, default -> "Null or unrecognized: " + obj;
        // both null AND anything not matched above receive THIS same treatment
    };
}

// ── Guarded (conditional) patterns don't by themselves satisfy exhaustiveness
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}

// double areaBad(Shape shape) {
//     return switch (shape) {
//         case Circle c when c.radius() > 0 -> Math.PI * c.radius() * c.radius();
//         case Square s -> s.side() * s.side();
//         // COMPILE ERROR — "Circle c when ..." is a CONDITIONAL pattern; the guard
//         // might be false at runtime, so Circle isn't provably fully covered —
//         // exhaustiveness over the sealed Shape hierarchy is NOT satisfied
//     };
// }

// Fix — add an unconditional fallback for the guarded type, or a default:
double areaFixed(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 0 -> Math.PI * c.radius() * c.radius();
        case Circle c -> 0.0;                  // unconditional fallback for Circle — now covered
        case Square s -> s.side() * s.side();
        // exhaustive now — Circle is fully covered (guarded + unconditional fallback),
        // Square is fully covered (unconditional) — no default required
    };
}

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.