Enums, Records & Sealed Types

Pattern Matching for switch

15 min Lesson 9 of 13

Pattern Matching for switch

Java 21 (finalized after preview in 17 and 18–20) brought pattern matching for switch — one of the most expressive additions to the language in years. Combined with sealed hierarchies, it lets you write concise, exhaustive, and compiler-verified code that was previously only possible through verbose chains of instanceof checks and casts.

The problem before pattern matching

Suppose you have a sealed Shape hierarchy from the previous lesson:

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

Before Java 21, computing the area required a cascade of instanceof checks:

static double area(Shape s) { if (s instanceof Circle c) { return Math.PI * c.radius() * c.radius(); } else if (s instanceof Rectangle r) { return r.width() * r.height(); } else if (s instanceof Triangle t) { return 0.5 * t.base() * t.height(); } else { throw new IllegalStateException("Unknown shape: " + s); } }

This works, but the compiler cannot tell you if you missed a branch. The else throw is a runtime fallback you must remember to write. It also does not scale well as the hierarchy grows.

Type patterns in switch

Pattern matching for switch replaces that cascade with a single expression:

static double area(Shape s) { return switch (s) { 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(); }; }

Each case now holds a type pattern: the type to match followed by a binding variable. If the value matches, the binding variable is bound and in scope for that arm. Notice there is no default arm — the compiler knows the switch is exhaustive because Shape is sealed and all three permits are covered.

Exhaustiveness is checked at compile time. If you remove the Triangle case the compiler emits an error: "the switch expression does not cover all possible input values". This is the key benefit over the old instanceof chain — you cannot accidentally forget a subtype.

Guarded patterns (when clauses)

You can add a boolean guard to a case with the when keyword:

static String classify(Shape s) { return switch (s) { case Circle c when c.radius() > 100 -> "Large circle"; case Circle c -> "Small circle"; case Rectangle r when r.width() == r.height() -> "Square"; case Rectangle r -> "Rectangle"; case Triangle t -> "Triangle"; }; }

Guarded cases are matched top-to-bottom. A large circle matches the first arm; a small circle falls through to the second. Order matters when guards overlap.

Put more specific (guarded) cases first. A case without a guard acts as a catch-all for that type. If you put the unguarded case Circle c before the guarded one, the guarded case is unreachable and the compiler will warn you.

Null handling in switch

Traditionally, passing null to a switch throws NullPointerException. With pattern matching, you can handle it explicitly:

static String describe(Shape s) { return switch (s) { case null -> "No shape provided"; case Circle c -> "Circle with radius " + c.radius(); case Rectangle r -> "Rectangle " + r.width() + " x " + r.height(); case Triangle t -> "Triangle"; }; }

Without case null, a null argument would still throw NPE. Adding it makes the intent explicit and avoids surprises.

Combining with enums

Pattern matching also works on enums, and the compiler enforces exhaustiveness there too:

enum Day { MON, TUE, WED, THU, FRI, SAT, SUN } static String kind(Day d) { return switch (d) { case MON, TUE, WED, THU, FRI -> "Weekday"; case SAT, SUN -> "Weekend"; }; }

If a new constant is added to Day later, every switch over it that does not have a default will fail to compile — a very useful compiler-enforced safety net.

Deconstruction patterns (preview preview)

Java 21 also previews record deconstruction patterns in switch, letting you match and destructure in one step:

// Requires --enable-preview in Java 21 static String describeRecord(Shape s) { return switch (s) { case Circle(double r) -> "Circle r=" + r; case Rectangle(double w, double h) -> w + " x " + h; case Triangle(double b, double h) -> "base=" + b; }; }
Record deconstruction in switch is still in preview as of Java 21 and requires --enable-preview. Use the binding-variable form (case Circle c -> c.radius()) in production code for now.

switch statement vs. switch expression

Everything above uses switch expressions (with -> arms and a value). Pattern matching also works in switch statements (with : arms and break), but the expression form is strongly preferred: it is exhaustiveness-checked and returns a value directly, eliminating the need for a mutable local variable.

Summary

Pattern matching for switch unifies type testing, casting, and binding into a single, exhaustive construct. When paired with sealed classes and records, the compiler guarantees that every subtype is handled, and guards let you express fine-grained conditions without nested if chains. This trio — sealed types, records, and pattern matching — forms the modern Java approach to type-safe, data-oriented programming.