Pattern Matching
Pattern Matching
Pattern matching in Java is not one feature but three, finalized across three separate releases: pattern matching for instanceof (Java 16), record patterns, and pattern matching for switch (both Java 21). Together they replace the old combination of a type check, an explicit cast, and manual accessor calls with a single expression that checks, casts, and — for records — deconstructs a value all at once.
What Is Pattern Matching?
Pattern matching lets a single expression check a value's shape — its runtime type, and for a record, its components — and bind the matched pieces directly to new variables in the same expression. The design goal across all three JEPs was the same: remove the separate cast, and for records, the separate accessor calls, that used to be required after a type check had already proven what the value was.
Why Pattern Matching Was Introduced
Checking a value's type and then using it as that type used to take two separate steps — the check, and a cast repeating the same type a second time.
1// File: BeforePatternMatching.java
2
3public class BeforePatternMatching {
4
5 static int lengthOrDefault(Object obj) {
6 if (obj instanceof String) {
7 String s = (String) obj;
8 return s.length();
9 }
10 return -1;
11 }
12
13 public static void main(String[] args) {
14 System.out.println(lengthOrDefault("hello"));
15 System.out.println(lengthOrDefault(42));
16 }
17}Output:
5
-1
Pattern matching for instanceof collapses the check and the cast into one expression, binding the matched value directly to a new variable.
1// File: AfterPatternMatching.java
2
3public class AfterPatternMatching {
4
5 static int lengthOrDefault(Object obj) {
6 if (obj instanceof String s) {
7 return s.length();
8 }
9 return -1;
10 }
11
12 public static void main(String[] args) {
13 System.out.println(lengthOrDefault("hello"));
14 System.out.println(lengthOrDefault(42));
15 }
16}Output:
5
-1
s exists only where the compiler can prove the instanceof check succeeded — a rule called flow scoping, covered in depth below.
Syntax
Flow Scoping
A pattern variable from instanceof is only in scope where the compiler can prove the match succeeded. Inside the true branch of an if, that is straightforward — but the same variable can also be used after an if block, as long as every path reaching that point already required the match to have succeeded, such as when the if block itself always returns.
One sentence before the diagram: the compiler tracks exactly which lines are only reachable after a successful match, and that reachability is what defines a pattern variable's scope.
if (!(obj instanceof String s)) {
return -1; <- exits here whenever the match FAILED
}
// -------------------------------------------------------
// s is in scope from this point on, because every path that
// reaches this line already required the match to succeed
return s.length();
1// File: FlowScopingExample.java
2
3public class FlowScopingExample {
4
5 static int lengthOrDefault(Object obj) {
6 if (!(obj instanceof String s)) {
7 return -1;
8 }
9 // s is in scope here, because the only way to reach this line
10 // is for the instanceof check to have succeeded
11 return s.length();
12 }
13
14 public static void main(String[] args) {
15 System.out.println(lengthOrDefault("hello"));
16 System.out.println(lengthOrDefault(42));
17 }
18}Output:
5
-1
Record Patterns
A record pattern deconstructs a record's components directly, and each component can be given an explicit type or declared with var when the type is already obvious.
1// File: NestedRecordPatternExample.java
2
3public class NestedRecordPatternExample {
4
5 record Customer(String name, String tier) {}
6 record Order(String orderId, Customer customer) {}
7
8 public static void main(String[] args) {
9 Object obj = new Order("ORD-9001", new Customer("Meera", "Gold"));
10
11 if (obj instanceof Order(String orderId, Customer(String name, var tier))) {
12 System.out.println(orderId + " placed by " + name + " (" + tier + " tier)");
13 }
14 }
15}Output:
ORD-9001 placed by Meera (Gold tier)
Order's pattern nests a Customer pattern inside it, pulling orderId, name, and tier out in a single expression, with tier inferred via var while orderId and name are typed explicitly.
Guarded Patterns in switch
A case in a pattern-matching switch can attach a when clause for a further condition beyond the type match itself — covered in full in this article's real-world example below, which uses guarded patterns together with record pattern deconstruction.
The Dominance Rule
A switch case pattern must not be reachable only after a strictly more general pattern already matched everything it could match — the compiler rejects a more specific case placed after a more general one for the same value. This is covered as a common mistake later in this article.
Flow scoping is about runtime reachability; the dominance rule is a compile-time check on case ordering. They solve related problems — proving a pattern variable is safe to use, and proving a case is safe to keep — but neither one substitutes for the other.
Common Use Cases
A negated guard clause, exactly as FlowScopingExample demonstrates above, is one of the most common uses — checking that a value is not a particular type and returning early, then using the pattern variable for the rest of the method with no separate cast.
Deconstructing a sealed hierarchy inside a switch, combining record patterns with pattern matching for switch, replaces an instanceof chain with a single exhaustive expression — the core technique this series' Java 21 Features article and this article's own real-world example both build on.
Nested deconstruction of tree-like data, as NestedRecordPatternExample shows above, avoids a chain of accessor calls like order.customer().tier() when every level of the structure needs to be pulled apart at once.
Reducing verbosity with var inside a record pattern — when a component's type is already obvious from context, var avoids restating it, exactly as tier does in the nested example above.
Real-World Example
A support ticket routing system decides where to send an event — a new ticket, an escalation, or a resolution — based on both its type and specific field values, combining a sealed hierarchy, record patterns, guarded cases, and var inside a pattern.
1// File: TicketEvent.java
2
3public sealed interface TicketEvent
4 permits TicketEvent.Created, TicketEvent.Escalated, TicketEvent.Resolved {
5
6 record Created(String ticketId, String customerTier) implements TicketEvent {}
7 record Escalated(String ticketId, int escalationLevel) implements TicketEvent {}
8 record Resolved(String ticketId, int resolutionMinutes) implements TicketEvent {}
9}1// File: TicketEventRouter.java
2
3public class TicketEventRouter {
4
5 public String route(TicketEvent event) {
6 return switch (event) {
7 case TicketEvent.Created(String id, String tier) when tier.equals("Premium") ->
8 "Ticket " + id + " routed to priority queue (Premium customer)";
9 case TicketEvent.Created(String id, var tier) ->
10 "Ticket " + id + " routed to standard queue (" + tier + " customer)";
11 case TicketEvent.Escalated(String id, int level) when level >= 3 ->
12 "Ticket " + id + " routed to senior support (level " + level + ")";
13 case TicketEvent.Escalated(String id, int level) ->
14 "Ticket " + id + " routed to team lead (level " + level + ")";
15 case TicketEvent.Resolved(String id, int minutes) ->
16 "Ticket " + id + " closed after " + minutes + " minute(s)";
17 };
18 }
19}1// File: TicketEventRouterDemo.java
2
3public class TicketEventRouterDemo {
4 public static void main(String[] args) {
5 TicketEventRouter router = new TicketEventRouter();
6
7 TicketEvent premiumCreated = new TicketEvent.Created("T-4021", "Premium");
8 TicketEvent standardCreated = new TicketEvent.Created("T-4022", "Standard");
9 TicketEvent minorEscalation = new TicketEvent.Escalated("T-4023", 2);
10 TicketEvent majorEscalation = new TicketEvent.Escalated("T-4024", 3);
11 TicketEvent resolved = new TicketEvent.Resolved("T-4025", 45);
12
13 System.out.println(router.route(premiumCreated));
14 System.out.println(router.route(standardCreated));
15 System.out.println(router.route(minorEscalation));
16 System.out.println(router.route(majorEscalation));
17 System.out.println(router.route(resolved));
18 }
19}Output:
Ticket T-4021 routed to priority queue (Premium customer)
Ticket T-4022 routed to standard queue (Standard customer)
Ticket T-4023 routed to team lead (level 2)
Ticket T-4024 routed to senior support (level 3)
Ticket T-4025 closed after 45 minute(s)
A mistake that appears often in fresher pull requests is trying to express both a type check and a business condition in one bare instanceof, then re-checking the field manually inside the block anyway — losing the entire benefit of deconstruction. Attaching the condition directly as a when guard on the pattern, exactly as tier.equals("Premium") does here, keeps the condition and the shape of the data it applies to in one place.
Combining Pattern Matching With Other Features
Pattern matching for switch is designed around sealed classes, which is why this series' Java 17 and Java 21 articles build the same BookingResult hierarchy pattern matching is meant to consume. Record patterns are what make records worth using for structured data in the first place — a record with no matching deconstruction syntax would still need manual accessor calls. var inside a record pattern behaves exactly as var does everywhere else in this series' dedicated var article — inferred once, fixed from that point on.
Best Practices
Prefer the negated-instanceof-with-early-return form, as FlowScopingExample demonstrates, when a method's remaining logic only makes sense for the matched type — it avoids nesting the rest of the method inside an if block.
Reach for a when guard the moment a case needs a condition beyond the type match itself, rather than matching broadly and re-checking a field manually inside the case body.
Order switch cases from most specific to least specific for the same value, both because the dominance rule requires it and because it keeps the cases readable in the order a reader naturally expects to check them.
Use var inside a record pattern only when the component's type is genuinely obvious from context — for a component whose type carries real information, such as a domain-specific type in a large record, spelling it out is often clearer.
Common Mistakes
Placing a more general pattern before a more specific one for the same value violates the dominance rule and does not compile — the compiler can prove the later, more specific case could never be reached.
1// This does not compile - Object dominates String, making the
2// second case unreachable
3static String describe(Object obj) {
4 return switch (obj) {
5 case Object o -> "Some object";
6 case String s -> "A string: " + s;
7 };
8}Assuming a pattern-matching switch handles a null selector automatically is a second, genuinely runtime-demonstrable mistake — without an explicit case null label, a null selector still throws a NullPointerException.
1// File: SwitchPatternNullMistake.java
2
3public class SwitchPatternNullMistake {
4
5 sealed interface Notification permits Notification.Alert {
6 record Alert(String message) implements Notification {}
7 }
8
9 static String describe(Notification notification) {
10 return switch (notification) {
11 case Notification.Alert a -> "Alert: " + a.message();
12 };
13 }
14
15 public static void main(String[] args) {
16 try {
17 describe(null);
18 } catch (NullPointerException e) {
19 System.out.println("NullPointerException: no case null branch was provided");
20 }
21 }
22}Output:
NullPointerException: no case null branch was provided
Assuming a pattern variable from instanceof is in scope anywhere after the if block, rather than only where the compiler can prove the match succeeded, is a third mistake — one that surfaces as a compile error rather than a runtime surprise.
1// This does not compile - s is only in scope inside the if-block here,
2// since there is no guarantee the match succeeded once the block ends
3if (obj instanceof String s) {
4 System.out.println(s.length());
5}
6System.out.println(s.length());Interview Questions
Q1. What are the three JEPs that together make up Java's pattern matching feature, and in which versions were they finalized?
Pattern matching for instanceof (JEP 394) was finalized in Java 16. Record patterns (JEP 440) and pattern matching for switch (JEP 441) were both finalized together in Java 21, after both spent time as preview features in the releases between 17 and 21. Interviewers listen for whether you know these landed in two separate releases, not all three JEPs at once.
Q2. What is flow scoping, and how does it apply to a pattern variable from instanceof?
Flow scoping means a pattern variable is in scope exactly where the compiler can prove the instanceof check that introduced it must have succeeded — inside the true branch of the if, or after the if block when that block's only exits require the match to have already happened, such as an early return. The nuance being tested is whether you can explain the negated-and-early-return case, not just the simple positive case.
Q3. What is the dominance rule for switch pattern labels, and why does violating it cause a compile error?
It requires that no case pattern be strictly more general than, and placed before, a later case that could never be reached as a result. The compiler rejects this because the later case becomes provably dead code — every value it could match would already have been caught by the earlier, broader case. Product-company interviewers often ask you to reorder a broken example live to check you actually understand the rule, not just recognize it.
Q4. Can var be used inside a record pattern instead of an explicit type?
Yes. A record pattern component can be declared with var instead of its actual type when the type is already obvious from context, exactly as tier is declared with var inside the nested pattern in this article's NestedRecordPatternExample.
Q5. What happens when a pattern-matching switch's selector is null and there is no case null label?
It throws a NullPointerException at the point the switch is evaluated, exactly as demonstrated in this article's Common Mistakes section — pattern matching does not change a traditional switch's null-handling behavior unless an explicit case null branch is added. This is a classic "gotcha" question meant to catch candidates who assume pattern matching is somehow null-safe by default.
Q6. Can a record pattern be nested inside another record pattern?
Yes, to any depth — NestedRecordPatternExample in this article nests a Customer pattern inside an Order pattern, pulling values out of both levels in a single instanceof check with no intermediate accessor calls.
Q7. What role does a when guard play in pattern matching for switch, and does it affect exhaustiveness?
A when guard attaches a boolean condition to a case beyond its type match, and a guarded case alone never counts toward exhaustiveness, since the guard's condition might be false for some values of that type — an unconditional case for the same type, or a default, is still required to cover what the guard does not. The nuance interviewers are listening for is whether you know a guard alone never satisfies exhaustiveness, a detail many candidates get backwards.
FAQs
Is pattern matching for instanceof available before Java 21?
Yes, it was finalized earlier, in Java 16, well before record patterns and pattern matching for switch, which were both finalized together in Java 21.
Do record patterns work with instanceof as well as switch?
Yes. NestedRecordPatternExample in this article uses a record pattern directly inside an instanceof check; the same deconstruction syntax works identically inside a switch case.
Can a pattern variable from instanceof be reassigned like a normal variable?
Yes, within whatever scope it is valid in — it behaves like any other local variable of its inferred type once bound, with no special restrictions beyond the flow-scoping rules that govern where it is visible at all.
Does pattern matching for switch require the switched-on type to be sealed?
No, pattern matching for switch works with any type. Sealing only matters for exhaustiveness checking specifically — switching over a sealed type or an enum lets the compiler verify every case is covered without a default; switching over a non-sealed type still requires a default or an unconditional catch-all pattern.
Can guarded patterns be combined with record pattern deconstruction in the same case?
Yes, exactly as this article's real-world example does — case TicketEvent.Created(String id, String tier) when tier.equals("Premium") -> both deconstructs the record's components and attaches a further condition on one of them in a single case.
Is there a runtime performance cost to using pattern matching over a manual instanceof-and-cast chain?
No. Pattern matching for instanceof and for switch compile down to essentially the same type checks and casts a hand-written chain would use — the benefit is entirely in readability and compiler-checked correctness, not runtime behavior.
Can unnamed pattern variables be used to ignore a record component?
An underscore, _, can be used in place of a component name to signal that a particular part of a record pattern is intentionally unused, but this specific syntax shipped as a preview feature in Java 21 and was not finalized until Java 22 — it should not be relied on in production code targeting Java 21 itself.
Summary
Pattern matching turns what used to be a type check, a cast, and a set of accessor calls into a single expression — instanceof since Java 16, and record deconstruction inside switch since Java 21. Flow scoping is what makes the negated-check-and-early-return style work without repeating the cast, and the dominance rule is what keeps a switch over increasingly specific patterns honest at compile time rather than silently shadowing a case at runtime.
The habit worth carrying forward from this article's ticket-routing example is reaching for a when guard the moment a case needs more than a type match, and ordering cases from most specific to least specific so the dominance rule never gets in the way.
What to Read Next
Learn the newer, safer way to write a switch statement.