Records, Sealed Classes, and Modern Java
Java has evolved dramatically since Java 8. This page covers the most impactful features
introduced in Java 11 through 21 - the features that change how you write everyday code.
Feature table by version
Records
Records are immutable data carriers - classes that hold data and nothing else.
They eliminate the boilerplate of constructors, getters, equals(), hashCode(),
and toString().
Basic record
// Before records: ~40 lines of boilerplate
record Point(int x, int y) {}
// The compiler generates:
// - Constructor: Point(int x, int y)
// - Accessors: x(), y() (not getX() - no "get" prefix)
// - equals() based on all fields
// - hashCode() based on all fields
// - toString() "Point[x=1, y=2]"
Point p = new Point(1, 2);
System.out.println(p.x()); // 1
System.out.println(p); // Point[x=1, y=2]
System.out.println(p.equals(new Point(1, 2))); // true
Compact constructor (validation)
record Email(String address) {
// Compact constructor - no parameter list, fields assigned automatically
Email {
if (address == null || !address.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + address);
}
address = address.toLowerCase().strip();
}
}
Email e = new Email(" [email protected] ");
System.out.println(e.address()); // "[email protected]"
Records with methods
Records can have instance methods, static methods, and implement interfaces:
record Money(BigDecimal amount, String currency) implements Comparable<Money> {
// Static factory
static Money usd(double amount) {
return new Money(BigDecimal.valueOf(amount), "USD");
}
// Instance method
Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
@Override
public int compareTo(Money other) {
return this.amount.compareTo(other.amount);
}
}
When to use records vs classes
Sealed classes
Sealed classes restrict which classes can extend them. This enables exhaustive
pattern matching - the compiler knows all possible subtypes.
// Only Circle, Rectangle, and Triangle can extend Shape
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 {}
Exhaustive pattern matching
With sealed types, the compiler checks that you handle all cases:
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 knows all cases are covered
};
}
Sealed class hierarchies
sealed abstract class Vehicle permits Car, Truck, Motorcycle {}
final class Car extends Vehicle { // final - cannot be extended further
// ...
}
sealed class Truck extends Vehicle permits PickupTruck, SemiTruck {}
// Truck can only be extended by PickupTruck and SemiTruck
final class PickupTruck extends Truck {}
final class SemiTruck extends Truck {}
non-sealed class Motorcycle extends Vehicle {}
// non-sealed - anyone can extend Motorcycle
Pattern matching
Pattern matching for instanceof (Java 16)
Eliminates redundant casts:
// Before
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// After - binding variable 's' is in scope after the check
if (obj instanceof String s) {
System.out.println(s.length());
}
// Works with negation too
if (!(obj instanceof String s)) {
return; // s is NOT in scope here
}
// s IS in scope here (the method only continues if obj is a String)
System.out.println(s.length());
Pattern matching for switch (Java 21)
String describe(Object obj) {
return switch (obj) {
case Integer i when i > 0 -> "positive integer: " + i;
case Integer i -> "non-positive integer: " + i;
case String s -> "string of length " + s.length();
case null -> "null";
default -> "unknown: " + obj.getClass().getSimpleName();
};
}
Record patterns (Java 21)
Deconstruct records directly in patterns:
record Point(int x, int y) {}
void printCoords(Object obj) {
if (obj instanceof Point(int x, int y)) {
System.out.println("x=" + x + ", y=" + y);
}
}
// In switch
String quadrant(Point p) {
return switch (p) {
case Point(var x, var y) when x > 0 && y > 0 -> "I";
case Point(var x, var y) when x < 0 && y > 0 -> "II";
case Point(var x, var y) when x < 0 && y < 0 -> "III";
case Point(var x, var y) when x > 0 && y < 0 -> "IV";
default -> "on axis";
};
}
Nested record patterns
record Address(String city, String country) {}
record Customer(String name, Address address) {}
String greeting(Customer c) {
return switch (c) {
case Customer(var name, Address(_, var country))
when country.equals("DE") -> "Hallo " + name;
case Customer(var name, Address(_, var country))
when country.equals("FR") -> "Bonjour " + name;
case Customer(var name, _) -> "Hello " + name;
};
}
Switch expressions (Java 14)
Switch can now return a value and use arrow syntax:
// Expression (returns a value)
String dayType = switch (day) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
case SATURDAY, SUNDAY -> "Weekend";
};
// Multi-line with yield
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
case WEDNESDAY -> 9;
case THURSDAY, SATURDAY -> 8;
};
Text blocks (Java 15)
Multi-line strings without escape character chaos:
// Before
String json = "{\n" +
" \"name\": \"Alice\",\n" +
" \"age\": 30\n" +
"}";
// After
String json = """
{
"name": "Alice",
"age": 30
}
""";
// SQL
String sql = """
SELECT u.name, u.email
FROM users u
WHERE u.active = true
AND u.created > :since
ORDER BY u.name
""";
Text blocks strip common leading whitespace. The closing """ position determines
the indentation baseline.
Local variable type inference (var, Java 10)
// The compiler infers the type
var names = List.of("Alice", "Bob", "Charlie"); // List<String>
var map = new HashMap<String, List<Integer>>(); // HashMap<String, List<Integer>>
// Useful for complex generic types
var entrySet = map.entrySet(); // Set<Map.Entry<String, List<Integer>>>
// Works in for-loops
for (var entry : map.entrySet()) {
var key = entry.getKey();
var values = entry.getValue();
}
When to use var
Sequenced collections (Java 21)
New interfaces that add first/last access to ordered collections:
// SequencedCollection adds: getFirst(), getLast(), reversed()
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
String first = list.getFirst(); // "a"
String last = list.getLast(); // "c"
List<String> rev = list.reversed(); // [c, b, a] (view, not copy)
// SequencedMap adds: firstEntry(), lastEntry(), reversed()
var map = new LinkedHashMap<String, Integer>();
map.put("one", 1);
map.put("two", 2);
map.put("three", 3);
Map.Entry<String, Integer> first = map.firstEntry(); // one=1
Map.Entry<String, Integer> last = map.lastEntry(); // three=3
Best practices
Prefer records for data classes
If a class is just data (DTO, API response, value object), use a record. You get
immutability, value equality, and zero boilerplate.
Use sealed types for domain models
If you know all the variants (payment types, shapes, AST nodes), seal the hierarchy.
The compiler enforces exhaustive handling.
Adopt pattern matching gradually
Start with instanceof patterns (simple, safe), then move to switch patterns for
complex dispatch logic.
Don't overuse var
var is great for reducing noise in local variables where the type is obvious. Don't
use it when the type name provides important documentation.
Common pitfalls