08 - Records, Sealed Classes, Pattern Matching¶
What this session is¶
About an hour. You'll learn three modern Java features that, combined, let you model data and decisions in a way old Java couldn't. Records replace boilerplate-heavy data classes. Sealed classes restrict which classes can extend a type. Pattern matching in switch and instanceof makes type-based decisions clean.
If you've heard Java is "verbose" - this is the page that fixes that reputation.
Records: data classes without boilerplate¶
Recall the Account class from page 05:
public class Account {
private final String owner;
private double balance;
public Account(String owner, double balance) {
this.owner = owner;
this.balance = balance;
}
public String getOwner() { return owner; }
public double getBalance() { return balance; }
@Override
public String toString() {
return "Account{owner=" + owner + ", balance=" + balance + "}";
}
@Override
public boolean equals(Object o) { /* tedious */ }
@Override
public int hashCode() { /* tedious */ }
}
20+ lines for what's essentially "a pair of name and balance." Modern Java (16+):
One line. Java generates:
- A constructor taking owner and balance.
- Accessor methods owner() and balance() (note: no get prefix - the Java team chose shorter names).
- equals, hashCode, toString - all the things you'd write by hand.
- The class is implicitly final and the components are implicitly private final.
Use it:
var alice = new Account("Alice", 100.0);
System.out.println(alice.owner()); // Alice
System.out.println(alice.balance()); // 100.0
System.out.println(alice); // Account[owner=Alice, balance=100.0]
Two records with the same component values are .equals - they're treated as the same data:
var a = new Account("Alice", 100);
var b = new Account("Alice", 100);
System.out.println(a.equals(b)); // true
Records with validation¶
The auto-generated constructor accepts anything. For validation, write a compact constructor:
public record Account(String owner, double balance) {
public Account {
if (balance < 0) {
throw new IllegalArgumentException("balance cannot be negative");
}
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("owner required");
}
}
}
No parameter list, no this.x = x. The validation runs before Java assigns the fields. If validation passes, Java does the assignment automatically.
Records with extra methods¶
Records can have methods like any other class:
public record Rectangle(double width, double height) {
public double area() {
return width * height;
}
public double perimeter() {
return 2 * (width + height);
}
public Rectangle scale(double factor) {
return new Rectangle(width * factor, height * factor);
}
}
Records cannot have non-static instance fields beyond their components. They can have static fields, methods, and nested types. They're for immutable bundles of data with optional logic, not for arbitrarily-shaped classes.
When to use a record vs a class¶
| Use a record when | Use a class when |
|---|---|
| The type is a bundle of fixed data | You need mutable state |
| Two values with the same data should be equal | Identity matters (one object ≠ another with same data) |
| You want immutability | You need to inherit from another class |
| You're modeling a value (point, money, response DTO) | You're modeling behavior (service, controller, manager) |
Modern Java code uses records heavily for DTOs, value types, and many domain models.
Sealed classes/interfaces: closed hierarchies¶
Normally, anyone can extend your public class. Sealed types restrict who can:
public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
sealed interface Shape permits ... says: "only these three types are allowed to implement Shape." Anyone trying to add a fourth gets a compile error.
Why this matters: the compiler now knows the complete list of Shape subtypes. When you write a switch over a Shape, Java can verify you've covered every case. This combines with pattern matching to give you exhaustive case analysis - the kind functional languages have.
Pattern matching with switch¶
The "old way" of working with a polymorphic value (Shape) was a chain of instanceof + cast:
double area;
if (shape instanceof Circle) {
Circle c = (Circle) shape;
area = Math.PI * c.radius() * c.radius();
} else if (shape instanceof Square) {
Square s = (Square) shape;
area = s.side() * s.side();
} else if (shape instanceof Triangle) {
Triangle t = (Triangle) shape;
area = 0.5 * t.base() * t.height();
} else {
throw new IllegalStateException("unknown shape");
}
Modern Java (21+):
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
What's new:
- case Circle c - matches when shape is a Circle; binds the matched value as c.
- No default needed - because Shape is sealed, the compiler knows the three subtypes are the complete list. If you add a Pentagon implements Shape, every switch over Shape immediately gets a compile error pointing to where you forgot the case.
This is exhaustive pattern matching - the strongest correctness affordance modern Java offers. Use sealed + records + pattern-matching switch for any "value of several possible shapes" model.
Pattern matching with instanceof¶
For one-off type checks, pattern matching is built into instanceof too (Java 16+):
The c is bound when the instanceof is true. Inside the if, you have c already cast - no manual cast needed.
Record patterns: destructuring (Java 21+)¶
You can match on the components of a record:
double area = switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Square(double s) -> s * s;
case Triangle(double b, double h) -> 0.5 * b * h;
};
Cleaner - no .radius(), just the named component directly. Especially nice for nested records:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
Line line = new Line(new Point(0, 0), new Point(3, 4));
double length = switch (line) {
case Line(Point(int x1, int y1), Point(int x2, int y2)) ->
Math.hypot(x2 - x1, y2 - y1);
};
A complete worked example¶
Modeling a small "command" type with sealed + record + switch:
public sealed interface Command permits Add, Remove, List_, Quit {}
public record Add(String item) implements Command {}
public record Remove(String item) implements Command {}
public record List_() implements Command {} // empty record
public record Quit() implements Command {}
public class Cart {
private final java.util.List<String> items = new java.util.ArrayList<>();
public void handle(Command c) {
switch (c) {
case Add(String item) -> { items.add(item); System.out.println("added " + item); }
case Remove(String item) -> { items.remove(item); System.out.println("removed " + item); }
case List_() -> System.out.println(items);
case Quit() -> System.out.println("bye");
}
}
}
That switch is exhaustive - add a fifth Command type and the compiler will tell you exactly where to update.
Exercise¶
In a new file Shapes.java:
- Define
sealed interface Shape permits Circle, Square, Triangle. - Define records
Circle(double radius),Square(double side),Triangle(double base, double height)- each implements Shape. - Write a static method
area(Shape s)using aswitchexpression with record patterns (nodefault). - In
main, create a list of three shapes (one of each) and print each shape's area.
Expected output:
Circle[radius=2.0] -> 12.566370614359172
Square[side=3.0] -> 9.0
Triangle[base=4.0, height=5.0] -> 10.0
Stretch: add a fourth shape type (e.g., Pentagon). Remove the default case from your switch and try to compile. You should get a clear "non-exhaustive" error pointing at the switch. Add the Pentagon case; recompile.
What you might wonder¶
"Why no default in the switch?"
Because the sealed hierarchy is closed. The compiler can prove you handled every possibility. Adding default defeats the exhaustiveness check - you want the compile error when you add a new subtype, not silent fallback.
"Can I extend a record?"
No - records are implicitly final. The whole point is "this represents data; nothing should be in the way."
"What about Lombok?"
Lombok is a third-party library that generates equals/hashCode/toString/constructors via annotations (@Data, @Builder). Records cover most of what Lombok's @Value and @Data did, without a third-party tool. New code: use records. Existing Lombok code: leave it.
"Are there other patterns?"
Yes - null patterns, var patterns, and guard conditions (case Circle c when c.radius() > 100 -> ...). Look these up when you encounter them; they extend the pattern-matching story.
"Does this slow things down?"
Records compile to regular classes; switch-with-patterns compiles to efficient bytecode (invokedynamic under the hood). No runtime overhead vs hand-written equivalents.
Done¶
You can now:
- Define records for immutable data bundles (replacing 20-line classes).
- Add validation via compact constructors.
- Define sealed hierarchies for closed sets of types.
- Use pattern matching in instanceof and switch.
- Use record patterns to destructure values.
- Recognize when the compiler is checking exhaustiveness for you.
This is one of the most productivity-changing modern Java features. Use it heavily.
Next page: generics - the type-parameter machinery you've been using implicitly with List<String>, explained properly.