Skip to content

Java 21: Pattern Matching for Switch

The Problem with Traditional Switch

Before Java 21, you knew switch as a tool for making decisions based on simple values. You could plug in an int, a String, a char, an enum, or any of their primitive wrapper types. The code read clearly and ran fast. But the moment you had an Object in your hands and wanted to branch based on what type it was, switch would give you nothing. You were on your own with chains of if/else if blocks, each one doing instanceof checks and manual casts. It worked, but it was noisy, repetitive, and easy to get wrong.

Java 21 fixed this. Pattern matching for switch lets you write a switch on any object, automatically checking its type in each case, automatically doing the cast for you, and even letting you add extra conditions right inside the case label. This is the complete guide to how it works, why it was built this way, and what you need to know for technical interviews.

What You Already Know About Switch

Let's ground everything in what came before. The classic switch statement supported these types:

  • Primitive types: int, short, byte, char
  • Their wrapper classes: Integer, Short, Byte, Character
  • enum types
  • String (added in Java 7)

You could write it as a statement with break:

java
int day = 2;
switch (day) {
    case 1:
        System.out.println("Monday");
        break;
    case 2:
        System.out.println("Tuesday");
        break;
    default:
        System.out.println("Other");
}

Or as an expression with arrow labels (added in Java 14):

java
String result = switch (day) {
    case 1 -> "Monday";
    case 2 -> "Tuesday";
    default -> "Other";
};
System.out.println(result);

Java 21 pattern matching for switch keeps everything from both of those worlds. All the classic types still work. Arrow syntax still works. yield inside blocks still works. Nothing was taken away. What was added is the ability to match on objects by type.

Type Patterns in Switch: Matching by Type

Here is the core new ability. You now write case Type variableName as a case label, and Java checks whether the switch value is an instance of that type. If the check passes, the value is automatically cast and bound to the variable name you gave it.

java
Object obj = "Hello World";

switch (obj) {
    case String s -> System.out.println("String of length: " + s.length());
    case Integer i -> System.out.println("Integer value: " + i);
    case Double d -> System.out.println("Double value: " + d);
    default -> System.out.println("Something else");
}

Read that case String s label out loud: "if obj is an instance of String, cast it to String and call it s." That is exactly what happens. You can use s inside that case branch freely, with full type safety, no manual casting required.

Internally, the JVM is doing something equivalent to this:

java
if (obj instanceof String s) {
    System.out.println("String of length: " + s.length());
} else if (obj instanceof Integer i) {
    System.out.println("Integer value: " + i);
} else if (obj instanceof Double d) {
    System.out.println("Double value: " + d);
} else {
    System.out.println("Something else");
}

This is not magic. Pattern matching for switch is literally generating the same instanceof checks you would write by hand. The switch syntax is just a cleaner, more structured way to express them.

An Important Performance Note

Here is something most tutorials skip, and interviewers love to ask about. Traditional switch on primitive types is genuinely faster than if/else if. The JVM can compile those switches using a jump table or lookup table, which lets it jump directly to the right case in constant time without evaluating each condition one by one.

When you use pattern matching for switch with objects, that performance advantage disappears. The JVM must evaluate each case in order, doing an instanceof check at each step, which is exactly what a chain of if/else if would do. There is no jump table possible because types are not simple integer offsets.

So: object pattern switch versus if/else if chains? Roughly equivalent in speed. The benefit is purely readability and structure, not runtime performance. If someone asks you in an interview whether pattern matching switch is faster than if/else, the answer is no, not when dealing with objects.

Scope of Pattern Variables

The variable you declare in a case label belongs to that case block only. It is not visible in other case blocks, in the default block, or anywhere after the switch statement ends.

java
Object obj = "hello";

switch (obj) {
    case String s -> {
        System.out.println(s.toUpperCase()); // s is visible here
    }
    case Integer i -> {
        // s is NOT visible here
        System.out.println(i + 10);
    }
    default -> {
        // s and i are NOT visible here
        System.out.println("unknown");
    }
}
// s and i are NOT visible here either

This makes sense when you think about it. Each pattern variable only has a well defined type and value if that specific case matched. Letting it leak out would mean carrying around a variable that might not have been initialized, which Java rightly forbids.

Pattern Matching with Inheritance

This is where things get interesting and where you need to think carefully about what types are valid case labels.

Imagine this class hierarchy:

java
abstract class Vehicle {
    abstract void drive();
}

class TwoWheeler extends Vehicle {
    @Override
    public void drive() { System.out.println("Riding two wheels"); }
}

class Bike extends TwoWheeler {
    @Override
    public void drive() { System.out.println("Riding bike"); }
}

class Cycle extends TwoWheeler {
    @Override
    public void drive() { System.out.println("Riding cycle"); }
}

class FourWheeler extends Vehicle {
    @Override
    public void drive() { System.out.println("Driving four wheels"); }
}

Now suppose you have a method that receives a TwoWheeler and needs to handle it:

java
void validate(TwoWheeler twObj) {
    switch (twObj) {
        case Bike b -> System.out.println("It's a bike: " + b);
        case Cycle c -> System.out.println("It's a cycle: " + c);
        case TwoWheeler tw -> System.out.println("Generic two wheeler: " + tw);
        // default not needed if sealed, but useful here
    }
}

The rule is simple: a type is only a valid case label if the switch value could possibly be an instance of that type. Since your switch value is a TwoWheeler, these are valid:

  • Bike is valid because a TwoWheeler reference can point to a Bike object
  • Cycle is valid for the same reason
  • TwoWheeler itself is valid because the object could be a TwoWheeler directly
  • Vehicle is valid because a TwoWheeler is always a Vehicle

This would be invalid:

java
case FourWheeler fw -> ... // Compile error! A TwoWheeler can NEVER be a FourWheeler

The compiler knows the type hierarchy and rejects cases that can never match.

The Duplicate Unconditional Pattern Error

Here is a specific compile error you will see when you make a logical mistake. Look at this:

java
void validate(TwoWheeler twObj) {
    switch (twObj) {
        case Bike b -> System.out.println("bike");
        case Cycle c -> System.out.println("cycle");
        case TwoWheeler tw -> System.out.println("two wheeler"); // catches everything remaining
        case Vehicle v -> System.out.println("vehicle"); // ERROR!
    }
}

This produces: "Duplicate unconditional pattern"

Why? Because case TwoWheeler tw is an unconditional pattern for the switch value type. Any TwoWheeler object, including Bike and Cycle, can reach the TwoWheeler case if none of the earlier ones matched. After TwoWheeler is already there to catch everything, adding Vehicle is redundant. Both would cover every remaining scenario, so Java flags this as a duplicate.

The fix: use one or the other. You do not need both TwoWheeler and Vehicle when your switch value is already a TwoWheeler.

Grouping Patterns Is Not Allowed

In a traditional switch, you can stack multiple labels before a single body:

java
// Traditional grouping (fine for simple values)
switch (day) {
    case 1:
    case 7:
        System.out.println("Weekend");
        break;
}

You might want to do the same with type patterns:

java
// THIS DOES NOT COMPILE
switch (obj) {
    case Circle c:
    case Square s:
        System.out.println("Shape"); // which variable do you use here?
}

Java rejects this. The error message is clear: "Multiple switch labels are permitted for a switch label group only if none of them declare any pattern variable."

Think about why. If you write case Circle c: case Square s: together, and both matched somehow, which variable would you use in the body? Is it c or is it s? There is no way to guarantee exactly one of them is initialized. So Java says: if any case label in a group declares a pattern variable, that label must stand alone.

You also cannot write case Circle c, Square s. That syntax is not valid for pattern cases. Each type pattern gets its own dedicated arrow or colon block.

Pattern Matching with Enums

Pattern matching works with enums too. Before Java 21, matching an enum in a switch looked like this:

java
enum Color { RED, GREEN, BLUE, YELLOW }

Object obj = Color.RED;

// Traditional approach
switch ((Color) obj) { // manual cast required!
    case RED:
        System.out.println("Red");
        break;
    case GREEN:
        System.out.println("Green");
        break;
    default:
        System.out.println("Other");
}

With pattern matching for switch in Java 21:

java
switch (obj) {
    case Color c -> System.out.println(c.name()); // prints RED, GREEN, etc.
    default -> System.out.println("Not a color");
}

The object is checked as an instance of Color. If it passes, it is automatically cast to Color and bound to c. You can then call c.name() or c.ordinal() or any other Color method directly. No manual cast, no noise.

Null Handling in Pattern Switch

This is a detail that catches many developers off guard, and it is a favorite interview question.

Pattern matching for switch is null safe on the type pattern matching side. When the switch value is null, instanceof checks return false. The JVM will not attempt to cast null and will not throw a NullPointerException just because of the type pattern itself.

java
Object obj = null;

switch (obj) {
    case String s -> System.out.println("String: " + s); // instanceof null is false, skipped
    case Integer i -> System.out.println("Integer: " + i); // also skipped
    default -> System.out.println("Handled null or unknown"); // lands here
}

When obj is null, every instanceof check evaluates to false, so every type pattern case is skipped. Execution falls through to default, which is where you handle the null case.

You can also handle null explicitly in Java 21 with a dedicated case null label:

java
switch (obj) {
    case null -> System.out.println("Got null!");
    case String s -> System.out.println("String: " + s);
    default -> System.out.println("Other");
}

This gives you precise control over null behavior rather than relying on default to catch it implicitly.

Guarded Patterns with the When Clause

Sometimes matching a type is not enough. You want to match a type AND verify some additional condition about the value. Before Java 21, you would match the type and then add an if statement inside the case body:

java
switch (obj) {
    case String s -> {
        if (s.contains("h") || s.contains("H")) {
            System.out.println("Contains h");
        } else {
            // fall through to next case? Not easily done here.
        }
    }
}

The problem is that once you are inside the case body, you cannot cleanly fall through to the next case. You are stuck handling it there or doing something awkward.

Java 21 solves this with the when clause. You add it directly to the case label:

java
switch (obj) {
    case String s when s.contains("h") || s.contains("H") ->
        System.out.println("String containing h: " + s);

    case String s ->
        System.out.println("String without h: " + s);

    default ->
        System.out.println("Not a string");
}

Read the first label: "if obj is an instance of String AND the string contains h or H." Both conditions must be true for that case to match. If the object is a String but does not contain h or H, execution moves to the second case String s which has no when clause and therefore matches any remaining String.

The when clause is always an AND relationship. The type pattern must match first, and then the when condition is evaluated. This mirrors how pattern matching for instanceof worked: you could use && to add conditions but not || between the type check and the extra condition.

To summarize the logic model:

case Type variable when condition -> body

Means: is the switch value an instance of Type? If yes, cast and bind to variable. Then evaluate condition. If both pass, execute body. Otherwise move to the next case.

Here is a practical example:

java
Object obj = 42;

String description = switch (obj) {
    case Integer i when i < 0 -> "negative integer";
    case Integer i when i == 0 -> "zero";
    case Integer i when i > 0 -> "positive integer";
    case String s when s.isBlank() -> "blank string";
    case String s -> "non blank string: " + s;
    default -> "something else";
};

System.out.println(description); // prints: positive integer

Exhaustiveness and Sealed Hierarchies

One of the most powerful combinations in Java 21 is using pattern matching for switch together with sealed classes and interfaces.

When you switch on a sealed type, Java knows at compile time every possible subtype that can exist. It can therefore verify that your switch covers all of them. If you forget one, the code will not compile.

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

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! Compiler verified all cases are covered.
    };
}

If you add a new subtype to the Shape sealed interface and forget to add a case for it here, the code stops compiling. The compiler acts as your completeness checker, preventing the subtle runtime bugs you would get if you used a traditional class hierarchy with a default that silently swallowed the unhandled new type.

Without sealed types, you need a default case because the compiler cannot know what types might exist:

java
// Non-sealed hierarchy: must have default
switch (obj) {
    case String s -> ...
    case Integer i -> ...
    default -> ... // required
}

With sealed types: default is optional if your cases are exhaustive.

Combining Everything: A Real Example

Here is a realistic example that brings together type patterns, guarded patterns, inheritance, and null handling:

java
sealed interface Notification permits EmailNotification, SmsNotification, PushNotification {}

record EmailNotification(String to, String subject, String body) implements Notification {}
record SmsNotification(String phoneNumber, String message) implements Notification {}
record PushNotification(String deviceToken, String title, boolean urgent) implements Notification {}

void send(Notification n) {
    switch (n) {
        case null ->
            System.out.println("Cannot send null notification");

        case EmailNotification e when e.body().isBlank() ->
            System.out.println("Email to " + e.to() + " has empty body, skipping");

        case EmailNotification e ->
            System.out.println("Sending email to " + e.to() + " subject: " + e.subject());

        case SmsNotification sms ->
            System.out.println("Sending SMS to " + sms.phoneNumber() + ": " + sms.message());

        case PushNotification push when push.urgent() ->
            System.out.println("URGENT push to device " + push.deviceToken() + ": " + push.title());

        case PushNotification push ->
            System.out.println("Push to device " + push.deviceToken() + ": " + push.title());
    }
}

This is clean, safe, and exhaustive. Adding a new Notification subtype to the sealed interface would immediately break this compile, forcing you to handle it. The when clauses let you handle edge cases inline without nested if statements cluttering the body.

Interview Questions and What Interviewers Are Looking For

Q: What types can be used as the switch selector in Java 21 pattern matching for switch?

Any type. Primitive types and their wrappers, String, enums, and now any class, abstract class, or interface. The restriction to a limited set of types was completely removed for pattern matching switches.

Q: Is pattern matching for switch faster than if/else when used with objects?

No. With object types, pattern matching for switch is internally converted to the same instanceof checks you would write by hand. There is no jump table or lookup table optimization. The benefit is purely readability. The performance advantage of switch over if/else only applies when switching on primitives and their wrappers.

Q: What is an unconditional pattern and why does Java reject duplicates?

An unconditional pattern is a type pattern that matches every possible value of the switch selector's type. For example, if your switch value is a TwoWheeler, then case TwoWheeler tw is unconditional because every TwoWheeler object will always match it. If you also add case Vehicle v, that is also unconditional for the same reason. Having two unconditional patterns creates an ambiguous, unreachable second case, so the compiler rejects it as a duplicate unconditional pattern.

Q: Can you group multiple type patterns before a single case body?

No. If any case label in a group declares a pattern variable, it must stand alone. You cannot write case Circle c: case Square s: together, because there is no way to determine which variable is guaranteed to be initialized in the shared body.

Q: How does pattern matching for switch handle null?

Type pattern cases use instanceof internally, and instanceof returns false for null. So a null value will skip all type pattern cases and fall to default. You can also write a dedicated case null label in Java 21 to handle null explicitly before it reaches default.

Q: What is the when clause and how does it relate to instanceof pattern matching?

The when clause adds an extra boolean condition to a case label. The case matches only if the type pattern matches AND the when condition is true. This is an AND relationship. This mirrors the behavior in instanceof pattern matching, where you could combine the type check with additional conditions using &&, but you could not express OR conditions between the type check and the extra guard. The when clause is basically that same && expressed as part of the case label rather than inside the body.

Q: When is a pattern matching switch exhaustive without a default?

When the switch is on a sealed type and every permitted subtype has a corresponding case label. The compiler tracks which subtypes are covered and reports a compile error if any are missing. For non sealed types, a default is always required because the compiler cannot enumerate all possible types that might exist at runtime.

Q: What is the scope of a pattern variable declared in a case label?

The pattern variable is scoped to the body of that specific case only. It is not visible in other case branches, in the default block, or in code after the switch statement ends.

Quick Reference Summary

FeatureBehavior
Type pattern case Type vChecks instanceof, casts, binds variable v
Pattern variable scopeThat case body only
Null handlinginstanceof is false for null; falls to default or case null
Explicit null labelcase null -&gt; available in Java 21
Guarded patterncase Type v when condition -&gt; AND semantics
OR between type and guardNot allowed
Grouping multiple patternsNot allowed if any pattern declares a variable
Invalid case typeType that can never be an instance of the selector type
Unconditional pattern duplicateCompile error when two cases cover every possible value
Sealed type exhaustivenessCompiler verifies all subtypes are covered, no default needed
Performance vs if/elseSame for objects; primitives still get jump/lookup table optimization
Finalized inJava 21 (previewed in earlier versions)

Connecting to What You Already Know

If you understand pattern matching for instanceof from Java 16, pattern matching for switch in Java 21 is the same idea applied to the switch statement and switch expression. The instanceof skill you already have transfers directly. The cases in a pattern switch are just multiple instanceof checks in a structured form. The when clause is just your && condition brought into the case label. The scope rules are identical. The null behavior is identical.

The new things are: the switch structure itself, the exhaustiveness checking with sealed types, and the when keyword as a dedicated syntax for guards. Everything else you already knew.

Practice reading pattern switch code by mentally substituting each case Type v when condition -&gt; with else if (selector instanceof Type v && condition). Once you can do that translation fluently in your head, you will have a deep understanding of what the compiler does with it.