Skip to main content

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

FeatureJava versionStatus
var (local variable type inference)10Final
HTTP Client API11Final
Switch expressions14Final
Text blocks15Final
Records16Final
Pattern matching for instanceof16Final
Sealed classes17Final
Pattern matching for switch21Final
Record patterns21Final
Virtual threads21Final
Sequenced collections21Final
String templates22 (preview)Preview

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(" USER@Example.COM ");
System.out.println(e.address()); // "user@example.com"

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

Use a record when...Use a class when...
Data is immutableYou need mutable state
Identity is based on data (value semantics)Identity is based on reference
You need equals/hashCode based on all fieldsYou need custom equals logic
Few fields, no inheritance neededYou need to extend a class
DTOs, API responses, value objectsEntities with behaviour, services

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
ModifierMeaning
sealedOnly the listed subtypes can extend this class
finalCannot be extended at all
non-sealedOpens up the hierarchy again (anyone can extend)

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

Use varAvoid var
Type is obvious from the right side (var list = new ArrayList<>())Type is not obvious (var result = process())
Complex generic types that add noiseMethod parameters or return types (not allowed)
Local variables in short methodsWhen the type name adds documentation value

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

PitfallProblemFix
Records are not beansRecords use name() not getName(), which breaks some frameworksUse @JsonProperty or configure Jackson for records
Records cannot extend classesRecords implicitly extend java.lang.RecordUse interfaces (records can implement multiple interfaces)
Sealed class in different filepermits clause requires all subtypes in the same package (or same file for nested)Keep sealed hierarchies in the same package
var with diamond operatorvar list = new ArrayList<>() infers ArrayList<Object>Either specify the type or use var list = new ArrayList<String>()
Pattern matching variable scopeBinding variables are only in scope where the pattern is guaranteed to matchBe careful with negated conditions and else branches

See also