Robust Oop

Sealed Classes & Pattern Matching

Pattern matching tests a value's type and binds it in one step (o instanceof Circle c), and the binding is in scope only where the test is known to be true. Switch pattern matching (JDK 21) dispatches on type, with when guards, case null, and dominance checks on case order. Record patterns take components apart directly, nesting as deep as the data. Over a sealed hierarchy, the switch is exhaustive with NO default, so a forgotten case is a compile error. Together, sealed types, records and pattern switches are Java's data-oriented design. An underscore (_) stands for a pattern or variable you never use. Every behavior is shown with verified output.

Suggest an edit

Sealed Classes & Pattern Matching — A Closed Set of Cases

Enums & Records introduced sealed (a closed set of subtypes) and record (a data carrier). This lesson is where they finally pay off:

  • Pattern matching tests a value's type and binds it to a variable, all in one move. o instanceof Circle c replaces the test-then-cast you wrote for equals and in Inheritance & Polymorphism.
  • Switch pattern matching (JDK 21) extends the switch so it can dispatch on type. Record patterns let you deconstruct a record's components inline.
  • Here's the keystone: over a sealed hierarchy, the compiler knows every possible subtype. So a switch that covers them all needs no default. Add a subtype the switch doesn't handle, and you get a compile error, not a silent fall-through.

That combination — sealed + record + pattern switch — is Java's answer to data-oriented programming.

💡 The core idea.

  • Pattern matching tests a type and binds it in one move — o instanceof Circle c.
  • Switch pattern matching dispatches on type; record patterns deconstruct components inline.
  • Over a sealed hierarchy, the switch is exhaustive with no default — a forgotten case is a compile error.
  • Together, sealed + record + pattern switch make up Java's data-oriented design.

Every output below was produced by compiling and running the code on Java 25.

You'll be able to: rewrite a test-then-cast as an instanceof with a binding, and predict where the binding is in scope; write a pattern switch with a when guard and a case null, and predict which case runs; predict whether a switch over a sealed type compiles without a default, and name the error when a case is missing or dominated; deconstruct a nested record with record patterns, and replace the parts you don't need with _.

📘 How to read the Intuition boxes. Each one is built in three moves:

  1. The mechanism — what the compiler and the JVM do.
  2. A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
  3. The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.

Table of contents

  1. Pattern matching for instanceof
  2. Switch pattern matching
  3. Exhaustiveness over a sealed hierarchy
  4. Record patterns
  5. Mental-model summary
  6. Gotcha checklist
  7. Check yourself
  8. Sources

1. Pattern matching for instanceof

A type pattern — o instanceof Type var — tests whether o is a Type, and if it is, binds it to var, already cast. It's been standard since Java 16 [1]. The binding is in scope wherever the test is known to be true, so test-and-cast becomes a single step.

Output:

string of length 5
int doubled = 42
unknown

Analysis. o instanceof String s did three things at once: it checked the type, cast the value, and bound s. That's why the branch could call s.length() without an explicit (String) o. Likewise, i was a ready-to-use Integer. Compare that with the old style from equals: if (!(o instanceof Point)) return false; Point p = (Point) o; The pattern collapses the redundant cast away.

Intuition. Mechanism. The compiler introduces the binding variable only where the pattern is provably true [5]. The cast is implicit and guaranteed safe, so you can't get a ClassCastException from a mismatched manual cast.

"Provably true" also covers the code after a negated test that returns. So the early-exit style works too:

Output:

5
-1

The if returns whenever o isn't a String. So on the line after it, o must be a String, and s is in scope there [10].

Concrete bite. The redundancy this removes is real. The classic instanceof + cast names the type twice, and the two can drift apart. The pattern names the type once and binds the result, so there's no second cast to get wrong.

Non-example: a binding where the test may have failed. With ||, the right side runs only when the left side is false — which is exactly when s was never bound:

Compiler error:

Main.java:4: error: cannot find symbol
        if (o instanceof String s || s.isEmpty()) {
                                     ^
  symbol:   variable s
  location: class Main
1 error

With &&, the same code compiles, because the right side runs only when the test succeeded. o instanceof String s && s.isEmpty() is the usual form.

💡 Earned rule. Wherever you check a type and then use it, write o instanceof Type var instead of a separate test and cast.

  • The cost is close to nothing: it's strictly less code.
  • The payoff: no redundant cast, no chance of a mismatched one, and a binding scoped exactly to where it's valid. It's also the building block for the switch patterns coming up next.

2. Switch pattern matching

Since JDK 21, a switch can match on type patterns [3]: each case Type var -> handles values of that type and binds the variable. This turns a chain of instanceof/else if into a clean multi-way dispatch.

Output:

string: hi
int: 42
other

Analysis. The switch matched each value against the type patterns: a String took the first case (binding s), an Integer took the second, and the Double fell through to default. This is the arrow switch expression, generalized from constants to types. Each case both tests the type and binds a usable variable, and the whole thing produces a value.

Intuition. Mechanism. The switch evaluates the selector once, then tries each case pattern from top to bottom, running the first one that matches and binding its variable. A pattern switch must be exhaustive: some case has to match every possible value [7]. That's true for a switch statement with patterns too, not just for an expression. With Object as the selector type, there are unboundedly many possible types, so you need a default.

Two more tools let you refine the cases [6]:

  • A guard, when condition, adds a test after the type matches.
  • case null handles a null selector, which otherwise matches no case.

Output:

empty string
long string: pattern
short string: hi
negative int
int: 7
nothing
other: 2.5

Analysis. "hi" matched String s three times, but the first two guards were false, so it reached the unguarded case String s. null went to case null. And 2.5 is a Double, which no case names, so it fell through to default.

Concrete bite. This replaces the verbose if (o instanceof A a) … else if (o instanceof B b) … ladder with a flat, value-producing form. The default is required only because the selector type (Object) is open. Close the type set with sealed, and the next section removes even that.

Non-example: a null selector without case null. default doesn't match null. Without a case null, a null selector throws a NullPointerException [8]:

Output (prints string: hi, then a thrown exception):

string: hi
Exception in thread "main" java.lang.NullPointerException

To fix it, add case null ->, or check for null before the switch.

Non-example: a general case before a specific one. Cases are tried in order. Put case CharSequence cs first, and it takes every String, so a later case String s could never run. javac calls that later case dominated, and rejects it [6]:

Compiler error:

Main.java:5: error: this case label is dominated by a preceding case label
            case String s -> "string: " + s;
                 ^
1 error

The fix is to put case String s first. Note that a guarded case doesn't dominate, which is why classify could list case String s when … before case String s.

💡 Earned rule. Whenever you'd otherwise write an instanceof chain to dispatch on a value's type, use a pattern switch. It's flatter, it binds each case, and it yields a value.

  • The cost: a default for open types, a case null when null can arrive, and care with ordering (specific before general).
  • The payoff: readable type-based dispatch, which becomes exhaustive and default-free over a sealed hierarchy.

3. Exhaustiveness over a sealed hierarchy

When the selector is a sealed type, the compiler knows every permitted subtype. So a switch that covers them all is exhaustive with no default — and if it misses one, you get a compile error.

Output:

12.57
9.00

Analysis. Shape permits exactly Circle and Square, so a switch that handles both is complete. No default was needed, and the compiler accepted it. Each case bound the matched shape and computed its area. This is the payoff of the whole design: the type system guarantees that the switch covers every case.

Intuition. Mechanism. Because sealed closes the subtype set [4], the compiler can verify that a switch handles every one of them [7]. Exhaustiveness is checked at compile time, so completeness is proven, not assumed.

Concrete bite. Leave out a permitted case, and it won't compile:

Compiler error:

Main.java:7: error: the switch expression does not cover all possible input values
        return switch (s) {

Square is missing, so the switch isn't exhaustive over Shape, and that's a compile error. Now here's the real win. Add a third permitted shape later, and every such switch stops compiling until you handle it. The compiler becomes your checklist of "places to update when the data model grows."

💡 Earned rule. Model a closed set of cases as a sealed hierarchy, and dispatch with a default-free switch, so exhaustiveness gets checked.

  • The cost: keeping the permits list and the switches in step — though the compiler enforces that for you.
  • The payoff: a new case can't silently slip through. A default, by contrast, would quietly swallow the new type at run time.

4. Record patterns

When the cases are records, a record pattern can deconstruct them [2]. case Circle(double r) -> matches a Circle and binds its component r directly, so you skip the accessor call.

Output:

12.57
12.00

Analysis. case Circle(double r) matched a Circle and bound its radius component to r in one step, with no c.radius() call. Rect(double w, double h) bound both components at once. The pattern mirrors the record's shape: you read its data by destructuring it, which is the inverse of constructing it.

Intuition. Mechanism. A record pattern matches the record type, then gets each component by invoking its accessor method [9]. Record patterns can nest, and var lets the compiler infer a component's type. So a single case can match and destructure nested data, and a guard can test the parts:

Output:

vertical line at x = 2
line from (0, 0) to (3, 4)
not a line

Analysis. The first Line has x1 == x2, so the guarded case took it. The second one failed the guard and fell through to the unguarded Line case. Four coordinates were bound in a single case, with no accessor calls and no casts.

Unnamed patterns and variables. The vertical-line case binds y1 and y2, then never reads them. Since JDK 22, you can write an underscore, _, for any pattern or variable that you have to declare but never use [11]:

Output:

vertical line at x = 2
some other line
not a line
count = 3
not a number

Analysis. Point(var x1, _) matched a Point with any y, without naming it. case Line _ matched every other Line and bound nothing. Outside patterns, _ stood in for a loop variable and a catch parameter that the code never reads. _ isn't a name, so you can't read it back: System.out.println(_) gives error: underscore not allowed here. What you gain is a declaration that says "unused" and that the compiler holds you to.

Concrete bite. This is the heart of data-oriented programming:

  • sealed defines the set of shapes data can take.
  • records define each shape's fields.
  • A pattern switch consumes them by destructuring, exhaustively.

Here, the behavior lives outside the data (in the switch) — the opposite of the polymorphism you saw in inheritance. It's a better fit when the operations vary more than the data does.

💡 Earned rule. Combine sealed + record + record-pattern switch to model and process closed sets of structured data: results, expressions, events, shapes.

  • The cost: you have to choose this style over class polymorphism. Use polymorphism when behavior travels with each type and the subtypes are open. Use sealed types and patterns when the type set is closed and operations get added from outside.
  • The payoff: concise, exhaustive, destructuring code that the compiler keeps complete.

5. Mental-model summary

Principle Consequence
o instanceof Type var tests and binds in one step Replaces test-then-cast; the binding is scoped to where it's true
A binding exists only where its test is known to be true After !(o instanceof T t) that returns, t is usable; on the right of ||, it is not
A pattern switch dispatches on type, binding each case Flattens an instanceof/else if chain into a value-producing form
A pattern switch must be exhaustive, statement or expression An open selector type needs default
when guards refine a case; case null handles null Without case null, a null selector throws NullPointerException, default or not
A case that an earlier unguarded case covers is dominated Specific cases before general ones, or javac rejects the switch
Over a sealed type, a covering switch needs no default The compiler proves exhaustiveness; a missing case won't compile
Record patterns deconstruct components, and nest case Line(Point(var x1, var y1), Point p2) binds parts directly
_ declares a pattern or variable you never use (JDK 22) case Line(Point(var x, _), _); catch (Exception _); _ can't be read

6. Gotcha checklist

Symptom Likely cause Fix
Still writing (Type) o after an instanceof the old test-then-cast use o instanceof Type var to bind the cast result once
cannot find symbol … variable s after an instanceof String s the binding is used where the test may have failed (the right of ||, an else) use &&, or the negated early-return form
the switch statement does not cover all possible input values a pattern switch statement over an open type with no default add default, or make the type sealed
the switch expression does not cover all possible input values, over a sealed type a permitted subtype is unhandled add its case (don't reach for default)
this case label is dominated by a preceding case label a general case sits above a specific one put the specific case first
NullPointerException from a switch that has a default default does not match null add case null ->
Adding a subtype silently does the wrong thing a default swallows it drop the default over a sealed type, so the compiler flags every switch
Calling accessors in every case — use a record pattern (case Rect(double w, double h)) to bind components directly

✅ Check yourself

One check per objective. Answer before you open anything.

The 🧪 box below: describe as a switch, the Json switch, and area(new Rect(2, 5)).

Output:

string of length 5
int doubled = 42
unknown
10.00
  • The switch version of describe prints the same three lines as the §1 if chain.
  • A Json switch that handles only JNum and JStr doesn't compile: you get the switch expression does not cover all possible input values. JNull is permitted, but unhandled.
  • area(new Rect(2, 5)) is 10.00. No default is needed, because Shape permits only Circle and Rect, and both have a case.

📚 Sources

  1. JEP 394: Pattern Matching for instanceof (JDK 16) — https://openjdk.org/jeps/394
  2. JEP 440: Record Patterns (JDK 21) — https://openjdk.org/jeps/440
  3. JEP 441: Pattern Matching for switch (JDK 21) — https://openjdk.org/jeps/441
  4. JEP 409: Sealed Classes (JDK 17) — https://openjdk.org/jeps/409
  5. The Java Language Specification, Java SE 25, §6.3.1 "Scope for Pattern Variables in Expressions" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-6.html#jls-6.3.1
  6. The Java Language Specification, Java SE 25, §14.11.1 "Switch Blocks" (guards; case null; "It is a compile-time error if any switch label in a switch block is dominated") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.11.1
  7. The Java Language Specification, Java SE 25, §14.11.1.1 "Exhaustive Switch Blocks" — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.11.1.1
  8. The Java Language Specification, Java SE 25, §14.11.3 "Execution of a switch Statement" (a null selector with no matching label throws NullPointerException) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.11.3
  9. The Java Language Specification, Java SE 25, §14.30.2 "Pattern Matching" ("Each record component of v is determined by invoking the accessor method") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.30.2
  10. The Java Language Specification, Java SE 25, §6.3.2.2 "if Statements" (a pattern variable introduced "when false" is in scope after an if whose body cannot complete normally) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-6.html#jls-6.3.2.2
  11. JEP 456: Unnamed Variables & Patterns (JDK 22) — https://openjdk.org/jeps/456

🧪 Predict, then check.

  1. Rewrite the §1 describe as a pattern switch, and predict its three outputs.
  2. Take sealed interface Json permits JNull, JNum, JStr {}, with records JNull(), JNum(double v) and JStr(String s). Predict whether a switch handling only JNum and JStr compiles, and the error if not.
  3. Predict the area printed by area(new Rect(2, 5)) using the §4 record pattern, and explain why no default is needed.

Your Turn

Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.

Mark as readBook versionfd26460